diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 26b7d31..e879664 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -792,6 +792,27 @@ esp_err_t game_endpoint_apply_step(const char *step_id, if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT; if (aerr != ESP_OK) return aerr; armed = "sound"; + } else if (binding.type == PB_LED || binding.type == PB_RADIO || + binding.type == PB_MORSE || binding.type == PB_NFC || + binding.type == PB_KEYPAD) { + // ── Level-A puzzles: parsed + validated + armed, but physical solve + // is STUBBED. There is no hardware driver yet (LED matrix / SDR / + // morse key / NFC reader / keypad), so we do NOT call any + // local_puzzles_arm_* and never report into puzzle_state — the puzzle + // is accepted and remains "pending" (unsolved). The step change still + // succeeds and s_current_step_id / s_current_armed reflect it so the + // GM dashboard and display can show the active puzzle type. + switch (binding.type) { + case PB_LED: armed = "led"; break; + case PB_RADIO: armed = "radio"; break; + case PB_MORSE: armed = "morse"; break; + case PB_NFC: armed = "nfc"; break; + case PB_KEYPAD: armed = "keypad"; break; + default: armed = "none"; break; + } + ESP_LOGW(TAG, "step %s: puzzle type '%s' (id %u) armed PENDING — " + "physical solve stubbed (no driver)", + step_id, armed, (unsigned) binding.id); } strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1); diff --git a/idf_zacus/components/game_endpoint/include/puzzle_binding.h b/idf_zacus/components/game_endpoint/include/puzzle_binding.h index 2b16c88..a03d0c3 100644 --- a/idf_zacus/components/game_endpoint/include/puzzle_binding.h +++ b/idf_zacus/components/game_endpoint/include/puzzle_binding.h @@ -11,7 +11,24 @@ #define PB_MAX_FRAG 4 // = PUZZLE_MAX_FRAG #define PB_MAX_PUZZLE_ID 8 // = PUZZLE_MAX_ID -typedef enum { PB_NONE = 0, PB_QR, PB_SOUND } puzzle_binding_type_t; +// ─── Level-A puzzle limits (mirror tools/scenario/runtime3_common.py +// _validate_step_puzzle EXACTLY) ──────────────────────────────────────── +#define PB_MAX_PATTERN 32 // led: 1..32 pattern strings +#define PB_MAX_PATSTR 32 // led: each pattern string < 32 chars +#define PB_MAX_MESSAGE 64 // morse: message string <= 63 chars (+ NUL) +#define PB_MAX_TAGS 32 // nfc: 1..32 tag strings +#define PB_MAX_TAGSTR 32 // nfc: each tag string < 32 chars +#define PB_KEYPAD_MIN 1 // keypad length 1..16 +#define PB_KEYPAD_MAX 16 + +typedef enum { + PB_NONE = 0, PB_QR, PB_SOUND, + // Level-A puzzle types (parsed + armed, physical solve stubbed): + PB_LED, PB_RADIO, PB_MORSE, PB_NFC, PB_KEYPAD, +} puzzle_binding_type_t; + +// morse mode (mirrors gateway "normal"|"light") +typedef enum { PB_MORSE_NORMAL = 0, PB_MORSE_LIGHT } puzzle_morse_mode_t; typedef struct { puzzle_binding_type_t type; // PB_NONE if the step has no puzzle @@ -22,7 +39,21 @@ typedef struct { // sound int melody[PB_MAX_NOTES]; size_t note_count; - int tolerance; // default 1 + int tolerance; // sound: default 1 / radio: required >=0 + // led + char pattern[PB_MAX_PATTERN][PB_MAX_PATSTR]; + size_t pattern_count; + // radio + int frequency; // >= 0 + // morse + char message[PB_MAX_MESSAGE]; + puzzle_morse_mode_t mode; + // nfc + char tags[PB_MAX_TAGS][PB_MAX_TAGSTR]; + size_t tag_count; + bool ordered; + // keypad + int length; // 1..16 // common uint8_t fragment[PB_MAX_FRAG]; uint8_t fragment_len; @@ -36,7 +67,10 @@ typedef struct { // long, fragment missing/empty/too long/non-digit values, negative tolerance). // NOTE: Parsing a full 64KB IR allocates transient heap via cJSON — call // sparingly (e.g. per step change, not per frame). -// Tolerance defaults to 1 when absent (sound only). +// Tolerance defaults to 1 when absent (sound only); for radio it is required. +// Level-A types (led/radio/morse/nfc/keypad) are parsed + validated against the +// same constraints as the gateway; their PHYSICAL solve is stubbed (they arm +// and stay pending — see game_endpoint_apply_step). esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, puzzle_binding_t *out); diff --git a/idf_zacus/components/game_endpoint/puzzle_binding.c b/idf_zacus/components/game_endpoint/puzzle_binding.c index 3a56079..f74399d 100644 --- a/idf_zacus/components/game_endpoint/puzzle_binding.c +++ b/idf_zacus/components/game_endpoint/puzzle_binding.c @@ -1,8 +1,11 @@ // puzzle_binding.c — parse a step's optional puzzle binding from Runtime 3 IR. #include "puzzle_binding.h" #include "cJSON.h" +#include "esp_log.h" #include +static const char *TAG = "puzzle_binding"; + esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, puzzle_binding_t *out) { @@ -60,7 +63,19 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, ptype = PB_QR; } else if (strcmp(jtype->valuestring, "sound") == 0) { ptype = PB_SOUND; + } else if (strcmp(jtype->valuestring, "led") == 0) { + ptype = PB_LED; + } else if (strcmp(jtype->valuestring, "radio") == 0) { + ptype = PB_RADIO; + } else if (strcmp(jtype->valuestring, "morse") == 0) { + ptype = PB_MORSE; + } else if (strcmp(jtype->valuestring, "nfc") == 0) { + ptype = PB_NFC; + } else if (strcmp(jtype->valuestring, "keypad") == 0) { + ptype = PB_KEYPAD; } else { + ESP_LOGW(TAG, "unknown puzzle type \"%s\" for step \"%s\"", + jtype->valuestring, step_id); cJSON_Delete(root); memset(out, 0, sizeof(*out)); return ESP_ERR_INVALID_ARG; @@ -120,7 +135,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, out->codes[i][PB_MAX_LABEL - 1] = '\0'; } out->code_count = (size_t)n; - } else { // PB_SOUND + } else if (ptype == PB_SOUND) { const cJSON *jmelody = cJSON_GetObjectItemCaseSensitive(puzzle, "melody"); if (!cJSON_IsArray(jmelody)) { cJSON_Delete(root); @@ -156,6 +171,127 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, } else { out->tolerance = 1; } + } else if (ptype == PB_LED) { + // led: pattern = list of 1..32 strings, each < 32 chars. + const cJSON *jpat = cJSON_GetObjectItemCaseSensitive(puzzle, "pattern"); + if (!cJSON_IsArray(jpat)) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + int n = cJSON_GetArraySize(jpat); + if (n < 1 || n > PB_MAX_PATTERN) { + ESP_LOGW(TAG, "led pattern count %d out of range [1,%d] (step %s)", + n, PB_MAX_PATTERN, step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + for (int i = 0; i < n; i++) { + const cJSON *pv = cJSON_GetArrayItem(jpat, i); + if (!cJSON_IsString(pv) || !pv->valuestring || pv->valuestring[0] == '\0' + || strlen(pv->valuestring) >= PB_MAX_PATSTR) { + ESP_LOGW(TAG, "led pattern[%d] invalid/too long (step %s)", i, step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + strncpy(out->pattern[i], pv->valuestring, PB_MAX_PATSTR - 1); + out->pattern[i][PB_MAX_PATSTR - 1] = '\0'; + } + out->pattern_count = (size_t)n; + } else if (ptype == PB_RADIO) { + // radio: frequency (int>=0) + tolerance (int>=0), both required. + const cJSON *jfreq = cJSON_GetObjectItemCaseSensitive(puzzle, "frequency"); + const cJSON *jtol = cJSON_GetObjectItemCaseSensitive(puzzle, "tolerance"); + if (!cJSON_IsNumber(jfreq) || jfreq->valueint < 0 + || !cJSON_IsNumber(jtol) || jtol->valueint < 0) { + ESP_LOGW(TAG, "radio frequency/tolerance missing or negative (step %s)", + step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + out->frequency = jfreq->valueint; + out->tolerance = jtol->valueint; + } else if (ptype == PB_MORSE) { + // morse: message (string <=63) + mode ("normal"|"light"). + const cJSON *jmsg = cJSON_GetObjectItemCaseSensitive(puzzle, "message"); + if (!cJSON_IsString(jmsg) || !jmsg->valuestring || jmsg->valuestring[0] == '\0' + || strlen(jmsg->valuestring) > PB_MAX_MESSAGE - 1) { + ESP_LOGW(TAG, "morse message missing/empty/too long (step %s)", step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + const cJSON *jmode = cJSON_GetObjectItemCaseSensitive(puzzle, "mode"); + if (!cJSON_IsString(jmode) || !jmode->valuestring) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + if (strcmp(jmode->valuestring, "normal") == 0) { + out->mode = PB_MORSE_NORMAL; + } else if (strcmp(jmode->valuestring, "light") == 0) { + out->mode = PB_MORSE_LIGHT; + } else { + ESP_LOGW(TAG, "morse mode \"%s\" not normal|light (step %s)", + jmode->valuestring, step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + strncpy(out->message, jmsg->valuestring, PB_MAX_MESSAGE - 1); + out->message[PB_MAX_MESSAGE - 1] = '\0'; + } else if (ptype == PB_NFC) { + // nfc: tags = list of 1..32 strings (<32 chars) + ordered (bool). + const cJSON *jtags = cJSON_GetObjectItemCaseSensitive(puzzle, "tags"); + if (!cJSON_IsArray(jtags)) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + int n = cJSON_GetArraySize(jtags); + if (n < 1 || n > PB_MAX_TAGS) { + ESP_LOGW(TAG, "nfc tags count %d out of range [1,%d] (step %s)", + n, PB_MAX_TAGS, step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + for (int i = 0; i < n; i++) { + const cJSON *tv = cJSON_GetArrayItem(jtags, i); + if (!cJSON_IsString(tv) || !tv->valuestring || tv->valuestring[0] == '\0' + || strlen(tv->valuestring) >= PB_MAX_TAGSTR) { + ESP_LOGW(TAG, "nfc tags[%d] invalid/too long (step %s)", i, step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + strncpy(out->tags[i], tv->valuestring, PB_MAX_TAGSTR - 1); + out->tags[i][PB_MAX_TAGSTR - 1] = '\0'; + } + out->tag_count = (size_t)n; + const cJSON *jord = cJSON_GetObjectItemCaseSensitive(puzzle, "ordered"); + if (!cJSON_IsBool(jord)) { + ESP_LOGW(TAG, "nfc ordered must be a bool (step %s)", step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + out->ordered = cJSON_IsTrue(jord); + } else { // PB_KEYPAD + // keypad: length (int 1..16). + const cJSON *jlen = cJSON_GetObjectItemCaseSensitive(puzzle, "length"); + if (!cJSON_IsNumber(jlen) + || jlen->valueint < PB_KEYPAD_MIN || jlen->valueint > PB_KEYPAD_MAX) { + ESP_LOGW(TAG, "keypad length out of range [%d,%d] (step %s)", + PB_KEYPAD_MIN, PB_KEYPAD_MAX, step_id); + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + out->length = jlen->valueint; } // id/type written last — all error exits above leave *out zeroed