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.
542 lines
21 KiB
C
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;
|
|
}
|