ace740a629
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.
274 lines
10 KiB
C
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;
|
|
}
|