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

220 lines
9.2 KiB
C

// Zacus npc_engine — ESP-IDF C port of the Arduino NPC decision engine.
//
// Source of truth for the Arduino implementation:
// ESP32_ZACUS/ui_freenove_allinone/src/npc/npc_engine.cpp
// ESP32_ZACUS/ui_freenove_allinone/include/npc/npc_engine.h
//
// The IDF port keeps the Arduino "core" state-machine API verbatim
// (npc_init / npc_evaluate / npc_on_*) because that code was already
// pure-C, side-effect free and free of Arduino-runtime dependencies.
//
// On top of that core the port adds an IDF-idiomatic "engine" wrapper
// layer with:
// * npc_engine_init(config) — boot the singleton, log readiness
// * npc_engine_update(now_ms) — periodic tick (mood + auto-evaluate)
// * npc_engine_trigger_cue(cue_id) — best-effort cue dispatch through
// media_manager_play()
// * npc_engine_set_step(step_id) — bridge to the scenario engine
// * npc_engine_request_hint(...) — async hint request (stubbed locally
// until the hints-engine HTTP client
// lands in a later slice)
//
// All wrapper entry points return `esp_err_t`. Callbacks are plain C
// function pointers — no C++ classes, no lambdas, RTOS-friendly.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// ── Core (ported verbatim from the Arduino sources) ─────────────────────────
#define NPC_MAX_SCENES 12
#define NPC_MAX_HINT_LEVEL 3
#define NPC_PHRASE_MAX_LEN 200
#define NPC_STUCK_TIMEOUT_MS (3UL * 60UL * 1000UL)
#define NPC_FAST_THRESHOLD_PCT 50
#define NPC_SLOW_THRESHOLD_PCT 150
#define NPC_QR_DEBOUNCE_MS 30000
typedef enum {
NPC_MOOD_NEUTRAL = 0,
NPC_MOOD_IMPRESSED,
NPC_MOOD_WORRIED,
NPC_MOOD_AMUSED,
NPC_MOOD_COUNT
} npc_mood_t;
typedef enum {
NPC_TRIGGER_NONE = 0,
NPC_TRIGGER_HINT_REQUEST,
NPC_TRIGGER_STUCK_TIMER,
NPC_TRIGGER_QR_SCANNED,
NPC_TRIGGER_WRONG_ACTION,
NPC_TRIGGER_FAST_PROGRESS,
NPC_TRIGGER_SLOW_PROGRESS,
NPC_TRIGGER_SCENE_TRANSITION,
NPC_TRIGGER_GAME_START,
NPC_TRIGGER_GAME_END,
NPC_TRIGGER_COUNT
} npc_trigger_t;
typedef enum {
NPC_AUDIO_NONE = 0,
NPC_AUDIO_LIVE_TTS,
NPC_AUDIO_SD_CONTEXTUAL,
NPC_AUDIO_SD_GENERIC
} npc_audio_source_t;
typedef struct {
uint8_t current_scene;
uint8_t current_step;
uint32_t scene_start_ms;
uint32_t total_elapsed_ms;
uint8_t hints_given[NPC_MAX_SCENES];
uint8_t qr_scanned_count;
uint8_t failed_attempts;
bool phone_off_hook;
bool tower_reachable;
npc_mood_t mood;
uint32_t last_qr_scan_ms;
uint32_t expected_scene_duration_ms;
} npc_state_t;
typedef struct {
npc_trigger_t trigger;
npc_audio_source_t audio_source;
char phrase_text[NPC_PHRASE_MAX_LEN];
char sd_path[128];
npc_mood_t resulting_mood;
} npc_decision_t;
void npc_init(npc_state_t *state);
void npc_reset(npc_state_t *state);
bool npc_evaluate(const npc_state_t *state, uint32_t now_ms,
npc_decision_t *out);
void npc_on_scene_change(npc_state_t *state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms);
void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms);
void npc_on_phone_hook(npc_state_t *state, bool off_hook);
void npc_on_hint_request(npc_state_t *state, uint32_t now_ms);
void npc_on_tower_status(npc_state_t *state, bool reachable);
void npc_update_mood(npc_state_t *state, uint32_t now_ms);
uint8_t npc_hint_level(const npc_state_t *state, uint8_t 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);
// ── Engine wrapper (IDF idioms) ─────────────────────────────────────────────
#define NPC_ENGINE_MAX_CUES 32
#define NPC_ENGINE_CUE_PATH_MAX 128
#define NPC_ENGINE_CUE_ID_MAX 32
// Static cue table entry. Authored cues live in flash; runtime state
// (already-played flag, cooldown) is tracked separately in RAM.
typedef struct {
char id[NPC_ENGINE_CUE_ID_MAX];
char audio_path[NPC_ENGINE_CUE_PATH_MAX];
uint8_t scene; // associated scene index (0xFF = global cue)
npc_mood_t mood;
} npc_cue_t;
// Configuration for the wrapper. `cues` may be NULL/0 — the engine still
// boots and accepts triggers (each `trigger_cue` call simply tries
// media_manager_play() with the supplied cue identifier as a path).
typedef struct {
const npc_cue_t *cues;
size_t cue_count;
bool auto_evaluate; // run npc_evaluate() each tick
bool auto_play_decisions; // dispatch decisions through media_manager_play
} npc_engine_config_t;
// Hint request callback. Invoked when the hints-engine produced a result
// (today: synchronously with a hardcoded stub text). `text` is owned by
// the engine and only valid for the duration of the call — copy if needed.
typedef void (*npc_hint_callback_t)(uint8_t puzzle_id, uint8_t level,
esp_err_t status, const char *text,
void *user_ctx);
// Initialize the engine singleton. `config` may be NULL — defaults are
// applied (no cue table, auto_evaluate=false, auto_play_decisions=false).
esp_err_t npc_engine_init(const npc_engine_config_t *config);
// Periodic tick. `now_ms` is the same monotonic millisecond counter as
// `media_manager_update`. Updates mood, optionally runs npc_evaluate and
// dispatches the resulting cue when `auto_*` flags are enabled.
esp_err_t npc_engine_update(uint32_t now_ms);
// Manually trigger a cue by id. Looks up the cue table and dispatches the
// associated audio path through media_manager_play(). When the cue is not
// in the table the engine treats `cue_id` itself as the path and forwards
// it as-is — useful for ad-hoc tests from the REST surface.
//
// Returns:
// ESP_OK cue dispatched (media_manager_play returned OK)
// ESP_ERR_NOT_FOUND cue id not in table AND raw path not playable
// ESP_ERR_INVALID_* propagated from media_manager
esp_err_t npc_engine_trigger_cue(const char *cue_id);
// Bridge from the scenario runtime — informs the engine of the active
// step (and implicitly the active scene). Resets failed-attempts counter
// 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.
// TODO(slice-6): replace with HTTP POST to /hints/ask on the hints engine.
esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level,
npc_hint_callback_t cb, void *user_ctx);
// Read-only access to the underlying core state — handy for diagnostics.
const npc_state_t *npc_engine_state(void);
// Slice 11 (P5): forward the global hints group profile to hints_client.
// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH".
// Thin wrapper kept here to give callers a single npc_engine_* surface.
esp_err_t npc_engine_set_group_profile(const char *profile);
// Point the engine at the voice gateway (tools/zacus-gateway). On each scene
// change the engine POSTs the active SCENE_* to {base_url}/game/step?scene=… so
// the phone NPCs disguise that scene's hint. Best-effort; pass NULL/"" to
// disable. `token` is sent as a Bearer header (gateway require_token).
void npc_engine_set_gateway(const char *base_url, const char *token);
// Write the active puzzle id (e.g. "SCENE_LA_DETECTOR") into `out`,
// truncated to `cap` bytes (NUL-terminated). Falls back to "SCENE_NPC"
// when no scene is active or the engine is not initialised so callers
// always have a non-empty id to send to the hints engine. Returns the
// number of bytes written excluding the trailing NUL.
size_t npc_engine_current_puzzle_id(char *out, size_t cap);
// Slice 11 (P5): notify the hints engine that the operator just made an
// invalid attempt on `scene`. Resolves the scene to the same string id
// returned by npc_engine_current_puzzle_id() and forwards through
// hints_client_attempt_failed(). Best-effort: returns ESP_OK even if the
// hints engine is unreachable, the failure is logged.
esp_err_t npc_engine_report_failed_attempt(uint8_t scene);
#ifdef __cplusplus
}
#endif