Files
ESP32_ZACUS/idf_zacus/components/npc_engine/npc_engine.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

542 lines
21 KiB
C

// Zacus npc_engine — ESP-IDF C port (slice 4, P1).
//
// Portage strategy: the Arduino sources were already pure C (no classes,
// no Arduino runtime calls), so the "core" half of this file is a verbatim
// copy of ui_freenove_allinone/src/npc/npc_engine.cpp with `cstring`/`cstdio`
// replaced by their C headers. The "engine wrapper" half is new and follows
// the same pattern as the media_manager component:
//
// * single static singleton, idempotent init
// * esp_err_t returns + ESP_LOG instrumentation
// * media_manager_play() integration for cue dispatch
//
// Hint requests are stubbed locally (synchronous callback with a hardcoded
// French placeholder). The real HTTP call to the hints engine lands in a
// later slice (see TODO below).
#include "npc_engine.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_err.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "media_manager.h"
#include "hints_client.h"
static const char *TAG = "npc_engine";
// Voice gateway (tools/zacus-gateway) — we push the active SCENE_* to it at each
// scene change so the phone NPCs disguise THIS scene's hint. Best-effort, set by
// npc_engine_set_gateway(); empty = disabled (no-op, e.g. CI / dry runs).
static char s_gw_url[128] = {0};
static char s_gw_token[80] = {0};
void npc_engine_set_gateway(const char *base_url, const char *token) {
if (base_url) snprintf(s_gw_url, sizeof(s_gw_url), "%s", base_url);
if (token) snprintf(s_gw_token, sizeof(s_gw_token), "%s", token);
}
// One-shot worker: POST {gateway}/game/step?scene=<scene> then self-delete.
// Runs on its own task so a slow/unreachable gateway NEVER stalls the scene
// transition (the game must not lag 2 s on every scene change if the gateway is
// offline). `arg` is a heap copy of the scene, freed here. Best-effort.
static void gw_notify_task(void *arg) {
char *scene = (char *) arg;
char url[256];
snprintf(url, sizeof(url), "%s/game/step?scene=%s", s_gw_url, scene);
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = 2000,
};
esp_http_client_handle_t cli = esp_http_client_init(&cfg);
if (cli) {
if (s_gw_token[0]) {
char auth[112];
snprintf(auth, sizeof(auth), "Bearer %s", s_gw_token);
esp_http_client_set_header(cli, "Authorization", auth);
}
esp_err_t err = esp_http_client_perform(cli);
if (err != ESP_OK) {
ESP_LOGW(TAG, "gateway /game/step notify failed: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "gateway scene -> %s (HTTP %d)", scene,
esp_http_client_get_status_code(cli));
}
esp_http_client_cleanup(cli);
}
free(scene);
vTaskDelete(NULL);
}
// Fire-and-forget the gateway scene notification on a detached task so a scene
// change is never blocked by the network. No-op if no gateway configured.
static void notify_gateway_scene(const char *scene) {
if (!s_gw_url[0] || !scene || !scene[0]) return;
size_t n = strlen(scene) + 1;
char *copy = malloc(n);
if (!copy) return;
memcpy(copy, scene, n);
if (xTaskCreate(gw_notify_task, "gw_notify", 4096, copy, 3, NULL) != pdPASS) {
ESP_LOGW(TAG, "gw_notify task spawn failed");
free(copy);
}
}
// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ──────────────
static const char *const kSceneIds[] = {
"SCENE_U_SON_PROTO",
"SCENE_LA_DETECTOR",
"SCENE_WIN_ETAPE1",
"SCENE_WARNING",
"SCENE_LEFOU_DETECTOR",
"SCENE_WIN_ETAPE2",
"SCENE_QR_DETECTOR",
"SCENE_FINAL_WIN",
};
static const uint8_t kSceneCount = sizeof(kSceneIds) / sizeof(kSceneIds[0]);
static const char *const kTriggerDirs[] = {
[NPC_TRIGGER_NONE] = "generic",
[NPC_TRIGGER_HINT_REQUEST] = "indice",
[NPC_TRIGGER_STUCK_TIMER] = "indice",
[NPC_TRIGGER_QR_SCANNED] = "felicitations",
[NPC_TRIGGER_WRONG_ACTION] = "attention",
[NPC_TRIGGER_FAST_PROGRESS] = "fausse_piste",
[NPC_TRIGGER_SLOW_PROGRESS] = "adaptation",
[NPC_TRIGGER_SCENE_TRANSITION] = "transition",
[NPC_TRIGGER_GAME_START] = "ambiance",
[NPC_TRIGGER_GAME_END] = "ambiance",
};
static const char *const kMoodSuffixes[] = {
[NPC_MOOD_NEUTRAL] = "neutral",
[NPC_MOOD_IMPRESSED] = "impressed",
[NPC_MOOD_WORRIED] = "worried",
[NPC_MOOD_AMUSED] = "amused",
};
// ─── Core: state-machine API (verbatim Arduino, NULL guards preserved) ──────
void npc_init(npc_state_t *state) {
if (state == NULL) return;
memset(state, 0, sizeof(*state));
state->mood = NPC_MOOD_NEUTRAL;
}
void npc_reset(npc_state_t *state) {
npc_init(state);
}
void npc_on_scene_change(npc_state_t *state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms) {
if (state == NULL) return;
state->current_scene = new_scene;
state->scene_start_ms = now_ms;
state->expected_scene_duration_ms = expected_duration_ms;
state->failed_attempts = 0;
}
void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms) {
if (state == NULL) return;
if (valid) {
state->qr_scanned_count++;
} else {
state->failed_attempts++;
}
state->last_qr_scan_ms = now_ms;
}
void npc_on_phone_hook(npc_state_t *state, bool off_hook) {
if (state == NULL) return;
state->phone_off_hook = off_hook;
}
void npc_on_hint_request(npc_state_t *state, uint32_t now_ms) {
if (state == NULL) return;
uint8_t scene = state->current_scene;
if (scene < NPC_MAX_SCENES && state->hints_given[scene] < NPC_MAX_HINT_LEVEL) {
state->hints_given[scene]++;
}
(void) now_ms;
}
void npc_on_tower_status(npc_state_t *state, bool reachable) {
if (state == NULL) return;
state->tower_reachable = reachable;
}
void npc_update_mood(npc_state_t *state, uint32_t now_ms) {
if (state == NULL || state->expected_scene_duration_ms == 0) return;
uint32_t elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
uint32_t pct = (elapsed * 100U) / expected;
if (state->failed_attempts >= 3) {
state->mood = NPC_MOOD_AMUSED;
} else if (pct < NPC_FAST_THRESHOLD_PCT) {
state->mood = NPC_MOOD_IMPRESSED;
} else if (pct > NPC_SLOW_THRESHOLD_PCT) {
state->mood = NPC_MOOD_WORRIED;
} else {
state->mood = NPC_MOOD_NEUTRAL;
}
}
uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene) {
if (state == NULL || scene >= NPC_MAX_SCENES) return 0;
return state->hints_given[scene];
}
bool npc_build_sd_path(char *out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant) {
if (out_path == NULL || capacity < 16) return false;
const char *scene_id = (scene < kSceneCount) ? kSceneIds[scene] : "npc";
const char *trigger_dir = (trigger < NPC_TRIGGER_COUNT)
? kTriggerDirs[trigger] : "generic";
const char *mood_str = (mood < NPC_MOOD_COUNT)
? kMoodSuffixes[mood] : "neutral";
bool is_scene_specific = (trigger != NPC_TRIGGER_GAME_START
&& trigger != NPC_TRIGGER_GAME_END
&& trigger != NPC_TRIGGER_NONE);
int written;
if (is_scene_specific && scene < kSceneCount) {
written = snprintf(out_path, capacity,
"/hotline_tts/%s/%s_%s_%u.mp3",
scene_id, trigger_dir, mood_str, (unsigned) variant);
} else {
written = snprintf(out_path, capacity,
"/hotline_tts/npc/%s_%s_%u.mp3",
trigger_dir, mood_str, (unsigned) variant);
}
return (written > 0 && (size_t) written < capacity);
}
bool npc_evaluate(const npc_state_t *state, uint32_t now_ms,
npc_decision_t *out) {
if (state == NULL || out == NULL) return false;
memset(out, 0, sizeof(*out));
uint32_t scene_elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
// Priority 1: Hint request (phone off hook while stuck)
if (state->phone_off_hook && scene_elapsed > NPC_STUCK_TIMEOUT_MS) {
uint8_t level = npc_hint_level(state, state->current_scene);
out->trigger = NPC_TRIGGER_HINT_REQUEST;
out->resulting_mood = state->mood;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_HINT_REQUEST,
state->mood, level);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 2: Stuck timer (proactive, no phone needed)
if (scene_elapsed > NPC_STUCK_TIMEOUT_MS
&& npc_hint_level(state, state->current_scene) == 0) {
out->trigger = NPC_TRIGGER_STUCK_TIMER;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_STUCK_TIMER,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 3: Fast progress detection
if (expected > 0 && scene_elapsed > 0
&& (scene_elapsed * 100U / expected) < NPC_FAST_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_FAST_PROGRESS;
out->resulting_mood = NPC_MOOD_IMPRESSED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_FAST_PROGRESS,
NPC_MOOD_IMPRESSED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 4: Slow progress detection
if (expected > 0 && (scene_elapsed * 100U / expected) > NPC_SLOW_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_SLOW_PROGRESS;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_SLOW_PROGRESS,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
return false;
}
// ─── Wrapper singleton (slice-4 IDF surface) ────────────────────────────────
typedef struct {
bool ready;
npc_state_t core;
const npc_cue_t *cues;
size_t cue_count;
bool played[NPC_ENGINE_MAX_CUES]; // already-played log
bool auto_evaluate;
bool auto_play_decisions;
} engine_t;
static engine_t s_engine;
static const npc_cue_t *find_cue(const char *cue_id) {
if (cue_id == NULL || s_engine.cues == NULL) return NULL;
for (size_t i = 0; i < s_engine.cue_count && i < NPC_ENGINE_MAX_CUES; ++i) {
if (strncmp(s_engine.cues[i].id, cue_id,
NPC_ENGINE_CUE_ID_MAX) == 0) {
return &s_engine.cues[i];
}
}
return NULL;
}
static size_t cue_index(const npc_cue_t *cue) {
if (cue == NULL || s_engine.cues == NULL) return SIZE_MAX;
return (size_t) (cue - s_engine.cues);
}
esp_err_t npc_engine_init(const npc_engine_config_t *config) {
memset(&s_engine, 0, sizeof(s_engine));
npc_init(&s_engine.core);
if (config != NULL) {
s_engine.cues = config->cues;
s_engine.cue_count = config->cue_count;
s_engine.auto_evaluate = config->auto_evaluate;
s_engine.auto_play_decisions = config->auto_play_decisions;
if (s_engine.cue_count > NPC_ENGINE_MAX_CUES) {
ESP_LOGW(TAG, "cue table truncated: %u > NPC_ENGINE_MAX_CUES (%u)",
(unsigned) s_engine.cue_count,
(unsigned) NPC_ENGINE_MAX_CUES);
s_engine.cue_count = NPC_ENGINE_MAX_CUES;
}
}
s_engine.ready = true;
ESP_LOGI(TAG, "npc_engine ready, %u cues registered "
"(auto_evaluate=%d, auto_play=%d)",
(unsigned) s_engine.cue_count,
(int) s_engine.auto_evaluate,
(int) s_engine.auto_play_decisions);
return ESP_OK;
}
esp_err_t npc_engine_update(uint32_t now_ms) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
s_engine.core.total_elapsed_ms = now_ms;
npc_update_mood(&s_engine.core, now_ms);
if (!s_engine.auto_evaluate) return ESP_OK;
npc_decision_t decision;
if (!npc_evaluate(&s_engine.core, now_ms, &decision)) return ESP_OK;
ESP_LOGI(TAG, "decision: trigger=%d mood=%d audio=%d path=\"%s\"",
(int) decision.trigger,
(int) decision.resulting_mood,
(int) decision.audio_source,
decision.sd_path);
if (s_engine.auto_play_decisions && decision.sd_path[0] != '\0') {
esp_err_t err = media_manager_play(decision.sd_path);
if (err != ESP_OK) {
ESP_LOGW(TAG, "auto-play \"%s\" failed: %s",
decision.sd_path, esp_err_to_name(err));
}
}
return ESP_OK;
}
esp_err_t npc_engine_trigger_cue(const char *cue_id) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (cue_id == NULL || cue_id[0] == '\0') return ESP_ERR_INVALID_ARG;
const npc_cue_t *cue = find_cue(cue_id);
const char *path = NULL;
size_t idx = SIZE_MAX;
if (cue != NULL) {
path = cue->audio_path;
idx = cue_index(cue);
ESP_LOGI(TAG, "trigger_cue id=\"%s\" -> path=\"%s\" (scene=%u, mood=%d)",
cue_id, path, (unsigned) cue->scene, (int) cue->mood);
} else {
// Fallback: treat the id itself as a path. Useful for ad-hoc REST tests.
path = cue_id;
ESP_LOGI(TAG, "trigger_cue id=\"%s\" not in table — playing raw path",
cue_id);
}
esp_err_t err = media_manager_play(path);
if (err == ESP_OK && idx < NPC_ENGINE_MAX_CUES) {
s_engine.played[idx] = true;
}
return err;
}
// Resolve a scene index to the canonical puzzle id used by the hints
// engine (matches game/scenarios/npc_phrases.yaml). Returns the count
// of bytes written (excluding NUL). Always writes at least the fallback
// "SCENE_NPC" so callers can rely on a non-empty string.
static size_t scene_to_puzzle_id(uint8_t scene, char *out, size_t cap) {
if (out == NULL || cap == 0) return 0;
const char *id = (scene < kSceneCount) ? kSceneIds[scene] : "SCENE_NPC";
int written = snprintf(out, cap, "%s", id);
if (written < 0) {
out[0] = '\0';
return 0;
}
return ((size_t) written < cap) ? (size_t) written : (cap - 1);
}
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
const uint8_t prev_scene = s_engine.core.current_scene;
s_engine.core.current_step = step_id;
npc_on_scene_change(&s_engine.core, step_id, expected_duration_ms,
s_engine.core.total_elapsed_ms);
ESP_LOGI(TAG, "step set to %u (expected_duration=%u ms)",
(unsigned) step_id, (unsigned) expected_duration_ms);
// Slice 11 (P5): notify the hints engine that the operator just
// entered a new pivot. Idempotent on the server, so we still post
// even if step_id hasn't moved (defensive: the scenario may rebind
// the same step after a recovery). hints_client logs the outcome
// internally — we don't propagate the failure (best-effort).
if (hints_client_is_ready()) {
char puzzle_id[NPC_ENGINE_CUE_ID_MAX];
scene_to_puzzle_id(step_id, puzzle_id, sizeof(puzzle_id));
if (step_id != prev_scene) {
ESP_LOGI(TAG, "scene changed %u → %u, signalling /puzzle_start",
(unsigned) prev_scene, (unsigned) step_id);
}
(void) hints_client_puzzle_start(puzzle_id);
// Keep the voice gateway in sync so the phone NPCs hint on THIS scene.
notify_gateway_scene(puzzle_id);
}
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
// need to depend directly on hints_client just for this knob.
if (!hints_client_is_ready()) {
ESP_LOGW(TAG, "set_group_profile(\"%s\") before hints_client_init",
profile ? profile : "(null)");
return ESP_ERR_INVALID_STATE;
}
return hints_client_set_group_profile(profile);
}
size_t npc_engine_current_puzzle_id(char *out, size_t cap) {
if (out == NULL || cap == 0) return 0;
uint8_t scene = s_engine.ready ? s_engine.core.current_scene : 0xFF;
return scene_to_puzzle_id(scene, out, cap);
}
esp_err_t npc_engine_report_failed_attempt(uint8_t scene) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (!hints_client_is_ready()) {
// Track locally only — keeps failed_attempts coherent so the
// stuck timer / mood updater still react.
s_engine.core.failed_attempts++;
ESP_LOGD(TAG, "failed_attempt scene=%u (hints offline, local only)",
(unsigned) scene);
return ESP_OK;
}
char puzzle_id[NPC_ENGINE_CUE_ID_MAX];
scene_to_puzzle_id(scene, puzzle_id, sizeof(puzzle_id));
s_engine.core.failed_attempts++;
(void) hints_client_attempt_failed(puzzle_id);
return ESP_OK;
}
esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level,
npc_hint_callback_t cb, void *user_ctx) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (cb == NULL) return ESP_ERR_INVALID_ARG;
const uint8_t clamped = (level > NPC_MAX_HINT_LEVEL)
? NPC_MAX_HINT_LEVEL : level;
npc_on_hint_request(&s_engine.core, s_engine.core.total_elapsed_ms);
// Slice 5: when the hints_client component has been initialised, route
// the request through the real HTTP backend asynchronously. Otherwise
// fall back to a hardcoded French placeholder so the surrounding NPC
// orchestration can still be exercised end-to-end (CI smoke, dry runs).
if (hints_client_is_ready()) {
// Slice 11 (P5): map the numeric puzzle hint id to the same
// SCENE_* string id used by /hints/puzzle_start. When the
// dispatcher passes id=0 (placeholder), fall back to the active
// scene so the hints engine can still pick a contextual answer.
char puzzle_str[NPC_ENGINE_CUE_ID_MAX];
const uint8_t scene_for_id = (puzzle_id == 0)
? s_engine.core.current_scene : puzzle_id;
scene_to_puzzle_id(scene_for_id, puzzle_str, sizeof(puzzle_str));
esp_err_t err = hints_client_ask_async(puzzle_str, puzzle_id, clamped,
(hints_client_callback_t) cb,
user_ctx, 0, 0);
if (err == ESP_OK) {
ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> hints_client async",
(unsigned) puzzle_id, (unsigned) clamped);
return ESP_OK;
}
ESP_LOGW(TAG, "hints_client_ask_async failed (%s) — using stub",
esp_err_to_name(err));
}
static const char *const kStubHints[NPC_MAX_HINT_LEVEL + 1] = {
"Regarde autour de toi, la solution est plus proche que tu ne crois.",
"As-tu pensé à observer chaque indice plus attentivement ?",
"Concentre-toi sur l'objet le plus inhabituel de la pièce.",
"Le code se trouve dans la séquence des couleurs, dans l'ordre.",
};
const char *text = kStubHints[clamped];
ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> stub \"%s\"",
(unsigned) puzzle_id, (unsigned) level, text);
cb(puzzle_id, clamped, ESP_OK, text, user_ctx);
return ESP_OK;
}
const npc_state_t *npc_engine_state(void) {
return s_engine.ready ? &s_engine.core : NULL;
}