Files
ESP32_ZACUS/idf_zacus/components/game_endpoint/puzzle_binding.c
T
clement ace740a629
CI / platformio (push) Failing after 6m10s
CI / platformio (pull_request) Failing after 17m59s
fix(npc): wire scene notify into apply_step
notify_gateway_scene() + hints puzzle_start lived only in
npc_engine_set_step(), which had NO callers — the runtime->gateway
sync was dead code, so scripted incoming calls would never fire in a
real game. POST /game/step goes through game_endpoint_apply_step(),
which now resolves the step's scene_id (new field on scene_binding,
parsed straight from the IR) and calls the new
npc_engine_set_scene_by_id(). Hardware-validated: STEP_WARNING ->
scene 3 -> gateway notify -> auto-ring Professeur Zacus on the PLIP.
2026-06-18 15:52:03 +02:00

274 lines
10 KiB
C

// puzzle_binding.c — parse a step's optional puzzle binding from Runtime 3 IR.
#include "puzzle_binding.h"
#include "cJSON.h"
#include <string.h>
esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
puzzle_binding_t *out)
{
if (!ir_json || !step_id || !out) return ESP_ERR_INVALID_ARG;
memset(out, 0, sizeof(*out));
cJSON *root = cJSON_Parse(ir_json);
if (!root) return ESP_ERR_INVALID_ARG;
if (!cJSON_IsObject(root)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; }
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
// Find the step with matching id.
const cJSON *step = NULL;
const cJSON *item = NULL;
cJSON_ArrayForEach(item, steps) {
const cJSON *sid = cJSON_GetObjectItemCaseSensitive(item, "id");
if (cJSON_IsString(sid) && strcmp(sid->valuestring, step_id) == 0) {
step = item;
break;
}
}
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
// Step found. Check for puzzle member.
const cJSON *puzzle = cJSON_GetObjectItemCaseSensitive(step, "puzzle");
if (!puzzle || cJSON_IsNull(puzzle)) {
// No puzzle — out->type stays PB_NONE, ESP_OK.
cJSON_Delete(root);
return ESP_OK;
}
// --- puzzle.id ---
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(puzzle, "id");
if (!cJSON_IsNumber(jid) || jid->valueint < 1 || jid->valueint > PB_MAX_PUZZLE_ID) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
// --- puzzle.type ---
const cJSON *jtype = cJSON_GetObjectItemCaseSensitive(puzzle, "type");
if (!cJSON_IsString(jtype)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
puzzle_binding_type_t ptype;
if (strcmp(jtype->valuestring, "qr") == 0) {
ptype = PB_QR;
} else if (strcmp(jtype->valuestring, "sound") == 0) {
ptype = PB_SOUND;
} else if (strcmp(jtype->valuestring, "morse") == 0) {
ptype = PB_MORSE;
} else if (strcmp(jtype->valuestring, "nfc") == 0) {
ptype = PB_NFC;
} else {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
// --- fragment (required) ---
const cJSON *jfrag = cJSON_GetObjectItemCaseSensitive(puzzle, "fragment");
if (!cJSON_IsArray(jfrag)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int frag_len = cJSON_GetArraySize(jfrag);
if (frag_len < 1 || frag_len > PB_MAX_FRAG) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < frag_len; i++) {
const cJSON *fv = cJSON_GetArrayItem(jfrag, i);
if (!cJSON_IsNumber(fv) || fv->valueint < 0 || fv->valueint > 9) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->fragment[i] = (uint8_t)fv->valueint;
}
out->fragment_len = (uint8_t)frag_len;
// --- type-specific fields ---
if (ptype == PB_QR) {
const cJSON *jcodes = cJSON_GetObjectItemCaseSensitive(puzzle, "codes");
if (!cJSON_IsArray(jcodes)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int n = cJSON_GetArraySize(jcodes);
if (n < 1 || n > PB_MAX_CODES) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < n; i++) {
const cJSON *cv = cJSON_GetArrayItem(jcodes, i);
if (!cJSON_IsString(cv)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strlen(cv->valuestring) >= PB_MAX_LABEL) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->codes[i], cv->valuestring, PB_MAX_LABEL - 1);
out->codes[i][PB_MAX_LABEL - 1] = '\0';
}
out->code_count = (size_t)n;
} else if (ptype == PB_SOUND) {
const cJSON *jmelody = cJSON_GetObjectItemCaseSensitive(puzzle, "melody");
if (!cJSON_IsArray(jmelody)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int n = cJSON_GetArraySize(jmelody);
if (n < 1 || n > PB_MAX_NOTES) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < n; i++) {
const cJSON *nv = cJSON_GetArrayItem(jmelody, i);
if (!cJSON_IsNumber(nv)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->melody[i] = nv->valueint;
}
out->note_count = (size_t)n;
// tolerance: optional, default 1, must be >= 0
const cJSON *jtol = cJSON_GetObjectItemCaseSensitive(puzzle, "tolerance");
if (jtol) {
if (!cJSON_IsNumber(jtol) || jtol->valueint < 0) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->tolerance = jtol->valueint;
} else {
out->tolerance = 1;
}
} else if (ptype == PB_MORSE) {
// puzzle.expected: required string, uppercase word, e.g. "ZACUS"
const cJSON *jexp = cJSON_GetObjectItemCaseSensitive(puzzle, "expected");
if (!cJSON_IsString(jexp) || !jexp->valuestring || !jexp->valuestring[0]) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strlen(jexp->valuestring) >= sizeof(out->morse_expected)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->morse_expected, jexp->valuestring,
sizeof(out->morse_expected) - 1);
out->morse_expected[sizeof(out->morse_expected) - 1] = '\0';
} else if (ptype == PB_NFC) {
// puzzle.uid: required string, colon-hex UID, e.g. "A1:B2:C3:D4"
const cJSON *juid = cJSON_GetObjectItemCaseSensitive(puzzle, "uid");
if (!cJSON_IsString(juid) || !juid->valuestring || !juid->valuestring[0]) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strlen(juid->valuestring) >= sizeof(out->nfc_uid)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->nfc_uid, juid->valuestring, sizeof(out->nfc_uid) - 1);
out->nfc_uid[sizeof(out->nfc_uid) - 1] = '\0';
}
// id/type written last — all error exits above leave *out zeroed
out->id = (uint8_t)jid->valueint;
out->type = ptype;
cJSON_Delete(root);
return ESP_OK;
}
// ─── Scene metadata (lenient — see header) ───────────────────────────────────
static void copy_scene_str(char *dst, size_t cap, const cJSON *node) {
if (cJSON_IsString(node) && node->valuestring) {
strncpy(dst, node->valuestring, cap - 1); // silent truncation OK
dst[cap - 1] = '\0';
}
}
esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
scene_binding_t *out)
{
if (!ir_json || !step_id || !out) return ESP_ERR_INVALID_ARG;
memset(out, 0, sizeof(*out));
out->effect = SB_FX_PULSE;
cJSON *root = cJSON_Parse(ir_json);
if (!root) return ESP_ERR_INVALID_ARG;
if (!cJSON_IsObject(root)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; }
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
const cJSON *step = NULL;
const cJSON *item = NULL;
cJSON_ArrayForEach(item, steps) {
const cJSON *sid = cJSON_GetObjectItemCaseSensitive(item, "id");
if (cJSON_IsString(sid) && strcmp(sid->valuestring, step_id) == 0) {
step = item;
break;
}
}
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
// Canonical scene id (SCENE_*), sibling of the step "id". Captured
// unconditionally so npc_engine / the voice gateway can be synced even
// on steps that carry no display "scene" object.
copy_scene_str(out->scene_id, sizeof(out->scene_id),
cJSON_GetObjectItemCaseSensitive(step, "scene_id"));
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
if (!scene || !cJSON_IsObject(scene)) {
// No scene object — present stays false, ESP_OK (scene_id may be set).
cJSON_Delete(root);
return ESP_OK;
}
out->present = true;
copy_scene_str(out->title, sizeof(out->title),
cJSON_GetObjectItemCaseSensitive(scene, "title"));
copy_scene_str(out->subtitle, sizeof(out->subtitle),
cJSON_GetObjectItemCaseSensitive(scene, "subtitle"));
copy_scene_str(out->symbol, sizeof(out->symbol),
cJSON_GetObjectItemCaseSensitive(scene, "symbol"));
const cJSON *fx = cJSON_GetObjectItemCaseSensitive(scene, "effect");
if (cJSON_IsString(fx) && fx->valuestring) {
if (strcmp(fx->valuestring, "glitch") == 0) out->effect = SB_FX_GLITCH;
else if (strcmp(fx->valuestring, "gyro") == 0) out->effect = SB_FX_GYRO;
else if (strcmp(fx->valuestring, "none") == 0) out->effect = SB_FX_NONE;
else out->effect = SB_FX_PULSE; // unknown → default, never an error
}
cJSON_Delete(root);
return ESP_OK;
}