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.
This commit was merged in pull request #171.
This commit is contained in:
@@ -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
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user