feat(voice): scenario-driven incoming calls
Repo State / repo-state (pull_request) Failing after 1m5s
Repo State / repo-state (push) Failing after 55s
Validate Zacus refactor / validate (pull_request) Failing after 6m20s
Validate Zacus refactor / validate (push) Failing after 13m3s

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.
This commit was merged in pull request #171.
This commit is contained in:
clement
2026-06-18 14:57:08 +02:00
parent 42a3b22823
commit 37eab47f63
2 changed files with 56 additions and 11 deletions
+9
View File
@@ -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
+47 -11
View File
@@ -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: "<number>"}` — 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)}