From 37eab47f63c4fc17ff7a741abad2461b19fac2fb Mon Sep 17 00:00:00 2001 From: clement Date: Thu, 18 Jun 2026 14:57:08 +0200 Subject: [PATCH] feat(voice): scenario-driven incoming calls The phone now rings on cue from the game with NO firmware change. A call_on_scene map (phone_directory.yaml: {SCENE_X: number}) tells the gateway which scenes are scripted incoming calls; since the runtime already pushes the scene to /game/step, the gateway auto-rings the PLIP with that NPC on scene entry (deduped, once per entry). Factored a _ring_board helper (shared by /game/call + auto-ring). Map ships empty (no surprise rings) with commented examples for the designer. --- game/scenarios/phone_directory.yaml | 9 +++++ tools/zacus-gateway/main.py | 58 +++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/game/scenarios/phone_directory.yaml b/game/scenarios/phone_directory.yaml index 5c2428b..baf18c5 100644 --- a/game/scenarios/phone_directory.yaml +++ b/game/scenarios/phone_directory.yaml @@ -47,3 +47,12 @@ numbers: Tu es le Professeur Zacus, savant excentrique et passionné, qui parle de ses expériences avec enthousiasme débordant. Tu glisses des indices déguisés dans tes divagations scientifiques sans jamais donner la solution directement. greeting: "Allô ? Oui, oui, Zacus à l'appareil ! Qui me dérange en pleine expérience ?!" + +# Scripted incoming calls: when the game enters one of these scenes, the gateway +# auto-rings the PLIP with that NPC number (the runtime already pushes the scene +# to /game/step, so no firmware change is needed). The NPC then disguises that +# scene's hint. One ring per scene entry. SCENE_* ids match zacus_v3.yaml +# screen_scene_id + npc_phrases.yaml. Uncomment / adjust to taste: +call_on_scene: {} + # SCENE_WARNING: "0142738200" # Professeur Zacus rappelle pour avertir + # SCENE_LEFOU_DETECTOR: "17" # Police secours sur l'Électron Fou diff --git a/tools/zacus-gateway/main.py b/tools/zacus-gateway/main.py index 302bd87..e18fbf3 100644 --- a/tools/zacus-gateway/main.py +++ b/tools/zacus-gateway/main.py @@ -221,6 +221,27 @@ def _active_scene(app) -> str: return STEP_SCENE.get(getattr(app.state, "current_step", INITIAL_STEP) or "", "") +def _load_call_on_scene() -> dict[str, str]: + """phone_directory.yaml `call_on_scene: {SCENE_X: ""}` — when the game + reaches SCENE_X the phone auto-rings that NPC. Lets the scenario drive incoming + calls with no firmware change (the runtime already pushes the scene).""" + path = SCENARIOS_DIR / "phone_directory.yaml" + if not path.is_file(): + return {} + return (yaml.safe_load(path.read_text(encoding="utf-8")) or {}).get("call_on_scene", {}) or {} + + +CALL_ON_SCENE: dict[str, str] = _load_call_on_scene() + + +async def _ring_board(http: httpx.AsyncClient, number: str, board: str = "plip") -> str: + """Ring a handset (board) with an NPC number. Returns the board base URL.""" + route = resolve_board_route(board) + r = await http.get(f"{route.base_url}/debug/ring", params={"number": number}, timeout=5.0) + r.raise_for_status() + return route.base_url + + # NOTE: in-process dict — single uvicorn worker only; multi-worker would need shared store (Redis). class VoiceSessions: """In-memory per-session chat history with TTL and turn cap.""" @@ -365,6 +386,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.audio_jobs = {} # job_id -> AudioJob (in-process async TTS queue) app.state.current_step = INITIAL_STEP # runtime updates via POST /game/step app.state.current_scene = STEP_SCENE.get(INITIAL_STEP or "", "") # SCENE_* the NPCs hint on + app.state.last_call_scene = None # last scene that auto-rang (dedup) STAGED_DIR.mkdir(parents=True, exist_ok=True) async def _prewarm_greetings() -> None: @@ -2262,11 +2284,25 @@ async def game_step(request: Request, step: str | None = None, request.app.state.current_scene = STEP_SCENE.get(step, "") if scene: request.app.state.current_scene = scene - if step or scene: - logging.info("game position -> scene %s", _active_scene(request.app)) sc = _active_scene(request.app) + if step or scene: + logging.info("game position -> scene %s", sc) + # Auto-ring: if this scene is a scripted incoming call (call_on_scene) and we + # just entered it, ring the PLIP with that NPC — the scenario drives the call + # with no extra runtime logic. Once per scene entry (track last_call_scene). + rang = None + num = CALL_ON_SCENE.get(sc) + if num and num in PHONE_DIRECTORY and getattr(request.app.state, "last_call_scene", None) != sc: + request.app.state.last_call_scene = sc + try: + await _ring_board(request.app.state.http, num) + rang = num + logging.info("auto-call: scene %s -> ring %s (%s)", sc, num, + PHONE_DIRECTORY[num].get("label")) + except Exception as exc: # noqa: BLE001 — best-effort + logging.warning("auto-call ring failed (%s): %s", num, type(exc).__name__) return {"step": getattr(request.app.state, "current_step", INITIAL_STEP), - "scene": sc, "has_hints": bool(SCENE_HINTS.get(sc, {}))} + "scene": sc, "has_hints": bool(SCENE_HINTS.get(sc, {})), "rang": rang} @app.post("/game/call") @@ -2283,15 +2319,15 @@ async def game_call(request: Request, number: str, board: str = "plip", if STEP_SCENE and step not in STEP_SCENE: raise HTTPException(404, f"unknown step '{step}'") request.app.state.current_step = step - route = resolve_board_route(board) - http: httpx.AsyncClient = request.app.state.http + request.app.state.current_scene = STEP_SCENE.get(step, "") try: - r = await http.get(f"{route.base_url}/debug/ring", params={"number": number}, timeout=5.0) - r.raise_for_status() + base = await _ring_board(request.app.state.http, number, board) + except HTTPException: + raise except Exception as exc: # noqa: BLE001 - raise HTTPException(502, f"ring failed on {board} ({route.base_url}): {type(exc).__name__}") from exc - logging.info("game call: ring %s on %s (number %s, step %s)", + raise HTTPException(502, f"ring failed on {board}: {type(exc).__name__}") from exc + logging.info("game call: ring %s on %s (number %s, scene %s)", PHONE_DIRECTORY[number].get("label", number), board, number, - getattr(request.app.state, "current_step", None)) + _active_scene(request.app)) return {"ok": True, "ringing": number, "npc": PHONE_DIRECTORY[number].get("label"), - "board": board, "step": getattr(request.app.state, "current_step", None)} + "board": board, "via": base, "scene": _active_scene(request.app)}