diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 8795046..8f263a4 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -22,4 +22,5 @@ idf_component_register( p7_coffre p5_morse p6_nfc + npc_engine ) diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index ffb53d7..e0469a7 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -27,6 +27,7 @@ #include "nvs_flash.h" #include "hints_client.h" +#include "npc_engine.h" #include "scenario_mesh.h" #include "puzzle_binding.h" #include "local_puzzles.h" @@ -807,6 +808,16 @@ esp_err_t game_endpoint_apply_step(const char *step_id, strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1); s_current_step_id[sizeof(s_current_step_id) - 1] = '\0'; + // Sync the NPC/hints engine (and, through it, the voice gateway) with the + // scene this step enters. This is what makes the phone NPCs hint on — and + // scripted calls auto-ring for — the current scene, regardless of whether + // the step was driven by the local game loop or an external POST /game/step. + // Best-effort: notify is async + server-side idempotent and never blocks + // the step change. Steps with no scene_id (or an unknown one) are skipped. + if (s_current_scene.scene_id[0] != '\0') { + (void) npc_engine_set_scene_by_id(s_current_scene.scene_id, 0); + } + // P7 coffre: fire the actuator when the final win step is reached. // p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n. if (strcmp(step_id, "STEP_FINAL_WIN") == 0) { diff --git a/idf_zacus/components/game_endpoint/include/puzzle_binding.h b/idf_zacus/components/game_endpoint/include/puzzle_binding.h index 4415a79..aa47870 100644 --- a/idf_zacus/components/game_endpoint/include/puzzle_binding.h +++ b/idf_zacus/components/game_endpoint/include/puzzle_binding.h @@ -55,6 +55,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, #define SB_MAX_TITLE 48 #define SB_MAX_SUBTITLE 64 #define SB_MAX_SYMBOL 16 +#define SB_MAX_SCENE_ID 40 typedef enum { SB_FX_PULSE = 0, // default (original SceneEffect::kPulse) @@ -65,6 +66,8 @@ typedef enum { typedef struct { bool present; // step has a scene object + char scene_id[SB_MAX_SCENE_ID]; // canonical SCENE_* id (sibling of step id; + // set even when no display scene object) char title[SB_MAX_TITLE]; char subtitle[SB_MAX_SUBTITLE]; char symbol[SB_MAX_SYMBOL]; diff --git a/idf_zacus/components/game_endpoint/puzzle_binding.c b/idf_zacus/components/game_endpoint/puzzle_binding.c index 279e093..bdcdd2d 100644 --- a/idf_zacus/components/game_endpoint/puzzle_binding.c +++ b/idf_zacus/components/game_endpoint/puzzle_binding.c @@ -239,9 +239,15 @@ esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id, } 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 — present stays false, ESP_OK. + // No scene object — present stays false, ESP_OK (scene_id may be set). cJSON_Delete(root); return ESP_OK; } diff --git a/idf_zacus/components/npc_engine/include/npc_engine.h b/idf_zacus/components/npc_engine/include/npc_engine.h index d320be3..d418c28 100644 --- a/idf_zacus/components/npc_engine/include/npc_engine.h +++ b/idf_zacus/components/npc_engine/include/npc_engine.h @@ -169,6 +169,16 @@ esp_err_t npc_engine_trigger_cue(const char *cue_id); // and primes the stuck timer. esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms); +// Same bridge as npc_engine_set_step(), but keyed by the canonical scene +// string id (e.g. "SCENE_WARNING") rather than its numeric index. Resolves +// the id against the engine's internal scene table and forwards to +// npc_engine_set_step(). Lets the game endpoint sync the NPC/hints engine +// (and the voice gateway) straight from the scenario IR's `scene_id`. +// Returns ESP_ERR_NOT_FOUND when the id matches no known scene (no notify +// is sent in that case), ESP_ERR_INVALID_ARG on a NULL/empty id. +esp_err_t npc_engine_set_scene_by_id(const char *scene_id, + uint32_t expected_duration_ms); + // Request a hint for `puzzle_id` at escalation `level` (0..3). The engine // invokes `cb` with the resulting text. The current implementation is a // LOCAL STUB: it returns a hardcoded French placeholder synchronously. diff --git a/idf_zacus/components/npc_engine/npc_engine.c b/idf_zacus/components/npc_engine/npc_engine.c index 7282338..87be587 100644 --- a/idf_zacus/components/npc_engine/npc_engine.c +++ b/idf_zacus/components/npc_engine/npc_engine.c @@ -440,6 +440,19 @@ esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) { return ESP_OK; } +esp_err_t npc_engine_set_scene_by_id(const char *scene_id, + uint32_t expected_duration_ms) { + if (scene_id == NULL || scene_id[0] == '\0') return ESP_ERR_INVALID_ARG; + for (uint8_t i = 0; i < kSceneCount; i++) { + if (strcmp(scene_id, kSceneIds[i]) == 0) { + return npc_engine_set_step(i, expected_duration_ms); + } + } + ESP_LOGW(TAG, "set_scene_by_id: unknown scene \"%s\" (no kSceneIds match)", + scene_id); + return ESP_ERR_NOT_FOUND; +} + esp_err_t npc_engine_set_group_profile(const char *profile) { // Thin pass-through. hints_client validates the value and logs the // outcome. Kept on npc_engine so the rest of the firmware doesn't