feat: NPC phrases, TTS generator, tests

- npc_phrases.yaml: full phrase bank (hints, personality,
  adaptation, bridges, false leads, ambiance) in French
- generate_npc_pool.py: batch TTS generator (Piper API)
- test_npc_engine.py: 21 unit tests for trigger logic
- test_npc_phrases_schema.py: YAML schema validation (12 tests)
- Fix FAST_PROGRESS guard: use absolute time limit to prevent
  false positives after several minutes on a scene

Impact: Sprint 3 NPC data layer complete. 33 tests passing.
This commit is contained in:
L'électron rare
2026-04-02 09:15:01 +02:00
parent 76491e4ed8
commit f4634f1dbe
5 changed files with 1340 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
**Le Mystère du Professeur Zacus** — hybrid educational escape room game with ESP32-S3 hardware, React authoring studio, and a portable Runtime 3 scenario engine. Scenarios are authored in YAML, compiled to a portable IR, and executed on both web and ESP32 targets.
## Build & Test
```bash
# Full validation pipeline
make all-validate
# Content checks (schema validation for scenarios, audio, printables)
make content-checks
# Runtime 3 — compile, simulate, verify, test
make runtime3-compile # YAML → Runtime 3 IR
make runtime3-simulate # simulate scenario execution
make runtime3-verify # verify pivot logic
make runtime3-test # Python unittest suite
# Frontend (React 19 + Blockly + Vite)
cd frontend-scratch-v2
npm test # Vitest (18 tests)
npm run build # tsc -b + vite build
npx vitest run tests/specific.test.ts # single test
# Compile a specific scenario
python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml
# Dev CLI (12 actions)
./tools/dev/zacus.sh content-checks
./tools/dev/zacus.sh runtime3-compile
./tools/dev/zacus.sh voice-bridge start|stop|status|test
```
## Architecture
### Data Flow
```
YAML scenario → compile_runtime3.py → Runtime 3 IR → ESP32 / Web player
Voice bridge → Piper TTS (Tower:8001)
Hints engine → /hints/ask (3 puzzles, 3 levels)
```
### Key Components
- **Scenario engine**: `game/scenarios/zacus_v2.yaml` is the canonical source. Runtime 3 spec in `specs/ZACUS_RUNTIME_3_SPEC.md`.
- **Frontend studio** (`frontend-scratch-v2/`): React 19 + Blockly 12.4 + Monaco Editor + Zod validation. Vite bundler, TypeScript.
- **ESP32 firmware** (`ESP32_ZACUS/` submodule): Freenove ESP32-S3, PlatformIO build. Separate repo with its own CI. Contains voice pipeline scaffold, audio/vision/QR detection, media manager.
- **Runtime 3 compiler** (`tools/scenario/compile_runtime3.py`): YAML → portable IR with pivots, zones, triggers.
- **Voice pipeline**: Piper TTS on Tower:8001 (3 voices: zacus=tom-medium, siwis, upmc), ESP-SR for wake word. Voice bridge routes `[HINT:puzzle:level]` to hints engine.
- **MCP hardware server** (`tools/dev/mcp_hardware_server.py`): 6 tools, stdio transport for hardware interaction.
- **TUI dashboard** (`tools/dev/zacus_tui.py`): 12 actions, logs, CI mode.
- **Analytics**: ESP32 module + 6 web endpoints + Dashboard UI.
- **NPC Engine** (`ESP32_ZACUS/ui_freenove_allinone/include/npc/npc_engine.h` + `src/npc/npc_engine.cpp`): Lightweight C state machine for Professor Zacus NPC. Trigger rules (stuck timer, QR scan, fast/slow progress, hint request), mood system (neutral/impressed/worried/amused), hybrid audio routing (live Piper TTS when Tower reachable, SD card MP3 fallback). NPC phrase bank in `game/scenarios/npc_phrases.yaml`.
- **TTS Client** (`ESP32_ZACUS/ui_freenove_allinone/include/npc/tts_client.h` + `src/npc/tts_client.cpp`): HTTP client for Piper TTS on Tower:8001 with health-check, PSRAM WAV buffer, and SD card fallback. Voice: tom-medium.
- **NPC Phrase Bank** (`game/scenarios/npc_phrases.yaml`): All Professor Zacus lines in French, organized by category: hints (3 levels × 6 scenes), congratulations, warnings, personality comments (by mood), adaptation phrases (skip/challenge/timer), narrative bridges, false leads, and ambiance (intro/outro/idle).
- **NPC MP3 Pool Generator** (`tools/tts/generate_npc_pool.py`): Python tool that reads `npc_phrases.yaml`, calls Piper TTS API for each phrase, writes MP3 files to `hotline_tts/`, and generates `hotline_tts/manifest.json`. Idempotent (skips already-generated files). Run: `python3 tools/tts/generate_npc_pool.py [--dry-run]`.
### AI Integration
- 6 AI agent definitions in `.github/agents/` (voice, tts, vision, hints, audio_gen, mcp)
- Hints engine: anti-cheat, 3 difficulty levels, per-puzzle context
- Spec: `specs/AI_INTEGRATION_SPEC.md`
## Canonical Files
| File | Role |
|------|------|
| `game/scenarios/zacus_v2.yaml` | Scenario source of truth (v3, Runtime 3) |
| `game/scenarios/npc_phrases.yaml` | Professor Zacus NPC phrase bank (all categories, French) |
| `tools/tts/generate_npc_pool.py` | NPC MP3 pool generator (Piper TTS → hotline_tts/) |
| `specs/ZACUS_RUNTIME_3_SPEC.md` | Runtime contract definition |
| `specs/AI_INTEGRATION_SPEC.md` | AI layer architecture |
| `Makefile` | Main automation entry point |
| `docs/QUICKSTART.md` | Getting started |
| `docs/DEPLOYMENT_RUNBOOK.md` | Field deployment |
| `docs/debt/codex-firmware-fixes-to-apply.md` | Pending firmware fixes (I2S, CORS, AP password) |
## Language & Communication
- User speaks **French**, code and docs in **English**
- Respond in French for conversation, English for code/comments/commits
## Infrastructure
- **Tower** (`clems@192.168.0.120`): Piper TTS FR on port 8001
- **KXKM-AI** (`kxkm@kxkm-ai`): RTX 4090, GPU inference
- SSH is key-based only, never use sshpass
+379
View File
@@ -0,0 +1,379 @@
# npc_phrases.yaml - Professor Zacus NPC phrase bank.
# All phrases in French. Organized by trigger category.
# Professor Zacus personality: eccentric, theatrical, science-enthusiast,
# slightly absent-minded, warm towards the children.
# Audio keys map to hotline_tts/ MP3 pool via generate_npc_pool.py.
#
# Structure:
# hints: {scene_id: {level_1: [], level_2: [], level_3: []}}
# congratulations: {scene_id: []}
# warnings: {scene_id: []}
# personality: {mood: []}
# adaptation: {type: []}
# bridges: {scene_id: []}
# false_leads: []
# ambiance: {type: []}
# ============================================================
# HINTS — 3 levels × 6 scenes
# level_1: subtle nudge
# level_2: clearer direction
# level_3: near-explicit solution
# ============================================================
hints:
SCENE_U_SON_PROTO:
level_1:
- text: "U-SON est sensible, vous savez… Il réagit à certaines fréquences précises. Cherchez bien autour de vous."
key: "hints.SCENE_U_SON_PROTO.level_1.0"
- text: "Le prototype a besoin d'une référence stable. Le son, c'est une question de précision !"
key: "hints.SCENE_U_SON_PROTO.level_1.1"
- text: "Hmm, vous cherchez quelque chose ? U-SON écoute… il attend un signal connu."
key: "hints.SCENE_U_SON_PROTO.level_1.2"
level_2:
- text: "Vous cherchez LA fréquence fondamentale. Celle qu'on utilise depuis des siècles pour accorder les instruments !"
key: "hints.SCENE_U_SON_PROTO.level_2.0"
- text: "U-SON attend le LA 440 Hz. C'est la norme internationale ! Vous l'avez peut-être vu quelque part dans la salle…"
key: "hints.SCENE_U_SON_PROTO.level_2.1"
level_3:
- text: "Approchez-vous du diapason sur l'établi. Frappez-le et présentez-le au micro d'U-SON. 440 Hz — le LA universel !"
key: "hints.SCENE_U_SON_PROTO.level_3.0"
- text: "Le diapason ! Oui, sur l'établi à gauche ! Frappez-le fort et approchez-le du capteur."
key: "hints.SCENE_U_SON_PROTO.level_3.1"
SCENE_LA_DETECTOR:
level_1:
- text: "Le détecteur est prêt. Il attend d'entendre quelque chose de… précis. De pur. De référencé."
key: "hints.SCENE_LA_DETECTOR.level_1.0"
- text: "C'est fascinant comme l'oreille humaine peut identifier une fréquence. Enfin, avec de la pratique…"
key: "hints.SCENE_LA_DETECTOR.level_1.1"
- text: "Hmm ! Le détecteur clignote ? C'est bon signe. Ou mauvais signe. Cherchez encore !"
key: "hints.SCENE_LA_DETECTOR.level_1.2"
level_2:
- text: "Le LA 440 Hz doit être produit directement devant le détecteur. Pas derrière, pas à côté — en face !"
key: "hints.SCENE_LA_DETECTOR.level_2.0"
- text: "Vous avez un diapason ? Parfait. Frappez-le sur votre genou et approchez-le du micro intégré au panneau."
key: "hints.SCENE_LA_DETECTOR.level_2.1"
level_3:
- text: "Le micro est ce petit rond noir sur le panneau. Diapason en main, frappez fort, maintenez 3 secondes devant le micro. UNLOCK_LA_440 sera déclenché !"
key: "hints.SCENE_LA_DETECTOR.level_3.0"
SCENE_WARNING:
level_1:
- text: "Zone 4 est différente. Ce n'est pas comme les autres zones du Campus. Lisez bien les règles affichées."
key: "hints.SCENE_WARNING.level_1.0"
- text: "L'Électron Fou a ses propres règles. Respectez-les et tout ira bien… enfin, j'espère."
key: "hints.SCENE_WARNING.level_1.1"
level_2:
- text: "Zone 4 appartient à l'Électron Fou. Ses instruments y sont installés. La règle : ne toucher qu'avec permission, et toujours annoncer son intention."
key: "hints.SCENE_WARNING.level_2.0"
- text: "Regardez bien le panneau d'avertissement. Il y a une procédure à suivre avant d'entrer en Zone 4."
key: "hints.SCENE_WARNING.level_2.1"
level_3:
- text: "Bouton NEXT sur le panneau ! C'est la procédure d'entrée en Zone 4. Appuyez dessus pour confirmer que vous avez lu les avertissements."
key: "hints.SCENE_WARNING.level_3.0"
SCENE_LEFOU_DETECTOR:
level_1:
- text: "Le piano de l'Électron Fou… chaque touche correspond à une lettre. C'est son langage secret !"
key: "hints.SCENE_LEFOU_DETECTOR.level_1.0"
- text: "LEFOU. Ça s'écrit comment, déjà ? L, E, F, O, U. Cinq lettres. Cinq notes. Réfléchissez !"
key: "hints.SCENE_LEFOU_DETECTOR.level_1.1"
- text: "Le piano-alphabet… Chaque lettre de l'alphabet correspond à une touche du piano. Fascinant, non ?"
key: "hints.SCENE_LEFOU_DETECTOR.level_1.2"
level_2:
- text: "Sur le piano, A=DO, B=RÉ, C=MI, D=FA, E=SOL, F=LA, G=SI… Et ainsi de suite. Épellez LEFOU en musique !"
key: "hints.SCENE_LEFOU_DETECTOR.level_2.0"
- text: "L-E-F-O-U. Chaque lettre est une note. Le tableau de correspondance est peut-être affiché quelque part dans la Zone 4…"
key: "hints.SCENE_LEFOU_DETECTOR.level_2.1"
level_3:
- text: "Regardez la partition sur le pupitre ! L=DO5, E=SOL, F=LA, O=MI5, U=DO6. Jouez ces cinq notes dans l'ordre sur le piano !"
key: "hints.SCENE_LEFOU_DETECTOR.level_3.0"
- text: "Les notes à jouer sont DO5, SOL, LA, MI5, DO6. Dans cet ordre exact. Doucement, mais avec confiance !"
key: "hints.SCENE_LEFOU_DETECTOR.level_3.1"
SCENE_QR_DETECTOR:
level_1:
- text: "Les Archives… un endroit mystérieux. Le QR code y est caché. Cherchez en hauteur, ou en profondeur !"
key: "hints.SCENE_QR_DETECTOR.level_1.0"
- text: "Pour accéder à la clé finale, il faut trouver le code QR. Il est dans cette salle… quelque part."
key: "hints.SCENE_QR_DETECTOR.level_1.1"
- text: "Regardez partout ! Derrière, dessous, dessus… Le code QR n'est pas toujours là où on l'attend."
key: "hints.SCENE_QR_DETECTOR.level_1.2"
level_2:
- text: "Le portrait du fondateur cache souvent des secrets dans cette salle. Regardez derrière lui !"
key: "hints.SCENE_QR_DETECTOR.level_2.0"
- text: "Le QR code est fixé au revers d'un cadre. Cherchez les objets qui peuvent être retournés ou soulevés."
key: "hints.SCENE_QR_DETECTOR.level_2.1"
level_3:
- text: "Derrière le grand portrait sur le mur du fond ! Soulevez-le délicatement. Le QR code contient le mot WIN. Scannez-le avec la tablette !"
key: "hints.SCENE_QR_DETECTOR.level_3.0"
SCENE_FINAL_WIN:
level_1:
- text: "Vous y êtes presque ! La victoire est à portée de main. Ne lâchez pas maintenant !"
key: "hints.SCENE_FINAL_WIN.level_1.0"
level_2:
- text: "C'est la dernière étape. Tout ce que vous avez appris mène ici. Faites confiance à votre équipe !"
key: "hints.SCENE_FINAL_WIN.level_2.0"
level_3:
- text: "Magnifique travail ! Vous avez réussi. Le Campus est sauvé et U-SON est stable. Félicitations !"
key: "hints.SCENE_FINAL_WIN.level_3.0"
# ============================================================
# CONGRATULATIONS — per scene (triggered on success/QR scan)
# ============================================================
congratulations:
SCENE_U_SON_PROTO:
- text: "Extraordinaire ! Le signal d'U-SON se stabilise. Vous avez trouvé la bonne approche !"
key: "congratulations.SCENE_U_SON_PROTO.0"
- text: "Bravo ! U-SON réagit positivement. La science est de votre côté aujourd'hui !"
key: "congratulations.SCENE_U_SON_PROTO.1"
SCENE_LA_DETECTOR:
- text: "Parfait ! LA 440 Hz détecté ! L'étape 1 est validée ! Le Campus respire mieux !"
key: "congratulations.SCENE_LA_DETECTOR.0"
- text: "Oui ! C'est ça ! 440 Hz ! Vous avez l'oreille absolue, ou presque ! Étape 1 : réussie !"
key: "congratulations.SCENE_LA_DETECTOR.1"
- text: "Magnifique précision ! Le détecteur confirme : LA 440 Hz. Mission accomplie pour l'Acte 1 !"
key: "congratulations.SCENE_LA_DETECTOR.2"
SCENE_WIN_ETAPE1:
- text: "Étape 1 validée ! U-SON est stabilisé ! Mais attention… il reste Zone 4 à respecter."
key: "congratulations.SCENE_WIN_ETAPE1.0"
- text: "Excellent travail d'équipe ! La première étape est franchie. Prenez un moment, puis passez en Zone 4."
key: "congratulations.SCENE_WIN_ETAPE1.1"
SCENE_LEFOU_DETECTOR:
- text: "LEFOU ! C'est exactement ça ! Le code musical de Zone 4 est accepté ! L'Électron Fou serait fier !"
key: "congratulations.SCENE_LEFOU_DETECTOR.0"
- text: "Les cinq notes sonnent juste ! Le piano-alphabet a parlé ! Zone 4 valide votre passage !"
key: "congratulations.SCENE_LEFOU_DETECTOR.1"
- text: "Bravo les musiciens improvvisés ! La mélodie LEFOU résonne dans tout le Campus !"
key: "congratulations.SCENE_LEFOU_DETECTOR.2"
SCENE_QR_DETECTOR:
- text: "WIN ! Le QR code est scanné ! La clé finale est reconnue ! Vous avez accompli quelque chose de remarquable !"
key: "congratulations.SCENE_QR_DETECTOR.0"
- text: "Fantastique ! Le code QR 'WIN' déverrouille le système ! Étape 2 validée ! Plus qu'un instant…"
key: "congratulations.SCENE_QR_DETECTOR.1"
SCENE_FINAL_WIN:
- text: "Vous avez réussi ! Le Campus est sauvé ! U-SON est stable, Zone 4 est respectée, et la clé finale est trouvée ! Je suis… je suis ému !"
key: "congratulations.SCENE_FINAL_WIN.0"
- text: "Victoire ! Vous avez prouvé que la coopération, la précision et le calme peuvent tout résoudre ! Chapeau bas !"
key: "congratulations.SCENE_FINAL_WIN.1"
- text: "C'était un test d'éthique autant que de science. Et vous avez réussi les deux ! Zacus est fier de vous !"
key: "congratulations.SCENE_FINAL_WIN.2"
# ============================================================
# WARNINGS — per scene (triggered on wrong action / failure)
# ============================================================
warnings:
SCENE_U_SON_PROTO:
- text: "Attention ! U-SON n'apprécie pas les bruits parasites. Soyez précis dans vos actions !"
key: "warnings.SCENE_U_SON_PROTO.0"
- text: "Hm… ce n'est pas tout à fait ça. U-SON cherche quelque chose de spécifique. Réfléchissez !"
key: "warnings.SCENE_U_SON_PROTO.1"
SCENE_LA_DETECTOR:
- text: "Ce n'est pas la bonne fréquence ! Le détecteur rejette le signal. Essayez autrement !"
key: "warnings.SCENE_LA_DETECTOR.0"
- text: "Raté ! La fréquence doit être précise à quelques hertz près. Recommencez avec plus de soin !"
key: "warnings.SCENE_LA_DETECTOR.1"
SCENE_WARNING:
- text: "Zone 4 requiert votre attention totale ! Lisez bien les instructions avant d'agir !"
key: "warnings.SCENE_WARNING.0"
SCENE_LEFOU_DETECTOR:
- text: "Fausse note ! L'Électron Fou est très attentif à la précision musicale. Recommencez depuis le début !"
key: "warnings.SCENE_LEFOU_DETECTOR.0"
- text: "Ce n'est pas la séquence correcte. LEFOU s'épelle lettre par lettre, note par note. Soyez méthodiques !"
key: "warnings.SCENE_LEFOU_DETECTOR.1"
- text: "Oups ! Le code musical a été rejeté. Vérifiez la correspondance lettre-note et réessayez !"
key: "warnings.SCENE_LEFOU_DETECTOR.2"
SCENE_QR_DETECTOR:
- text: "QR invalide ! Ce n'est pas le bon code. Le code que vous cherchez dit 'WIN'. Cherchez encore !"
key: "warnings.SCENE_QR_DETECTOR.0"
- text: "Le scanner n'a pas reconnu ce QR code. Il doit contenir exactement 'WIN'. Cherchez ailleurs !"
key: "warnings.SCENE_QR_DETECTOR.1"
- text: "Timeout ! Le scanner a expiré. Repositionnez le QR code bien en face de la caméra et réessayez !"
key: "warnings.SCENE_QR_DETECTOR.2"
# ============================================================
# PERSONALITY — by mood (contextual comments)
# ============================================================
personality:
impressed:
- text: "Remarquable ! Je n'aurais pas fait mieux. Et pourtant, j'ai un doctorat en sciences cognitives !"
key: "personality.impressed.0"
- text: "Stupéfiant ! Vous résolvez ça en un temps record. Je dois noter ça dans mes recherches !"
key: "personality.impressed.1"
- text: "Voilà ce que j'appelle de la vraie ingéniosité ! Le Campus a besoin de cerveaux comme les vôtres !"
key: "personality.impressed.2"
- text: "En 23 ans de recherche, j'ai rarement vu une telle rapidité d'exécution. Chapeau !"
key: "personality.impressed.3"
- text: "Extraordinary ! Euh… extraordinaire ! Je mélange mes langues quand je suis impressionné !"
key: "personality.impressed.4"
worried:
- text: "Hmm… vous prenez beaucoup de temps. Ce n'est pas un reproche, c'est… une observation scientifique."
key: "personality.worried.0"
- text: "Le temps passe, jeunes chercheurs. Pas de panique, mais… peut-être accélérez un peu ?"
key: "personality.worried.1"
- text: "Je commence à m'inquiéter. Pas pour vous, bien sûr… pour U-SON. Enfin, surtout pour U-SON."
key: "personality.worried.2"
- text: "Où en êtes-vous ? Mes instruments de mesure indiquent une légère inefficacité temporelle."
key: "personality.worried.3"
- text: "Avez-vous besoin d'aide ? Je suis là, vous savez. Sur la hotline. Décrochez si besoin !"
key: "personality.worried.4"
amused:
- text: "Ha ! Une autre tentative ! J'apprécie la persévérance. C'est ça, la vraie science : essayer, échouer, réessayer !"
key: "personality.amused.0"
- text: "Ah, vous avez encore fait une mauvaise manipulation ? Ne vous découragez pas. Même Edison a raté !"
key: "personality.amused.1"
- text: "C'est amusant, non ? Les erreurs sont les meilleures professeures. Du moins, c'est ce que je dis à mes étudiants."
key: "personality.amused.2"
- text: "Je comptais vos tentatives. C'est pour la recherche, bien sûr. Aucun jugement de ma part !"
key: "personality.amused.3"
neutral:
- text: "Vous progressez bien. Continuez ainsi, méthodiquement."
key: "personality.neutral.0"
- text: "Le Campus vous observe. Dans le bon sens du terme."
key: "personality.neutral.1"
- text: "Prenez votre temps. La science ne se précipite pas… enfin, pas trop."
key: "personality.neutral.2"
- text: "Intéressant. Continuez."
key: "personality.neutral.3"
- text: "Je prends des notes sur votre méthodologie. C'est fascinant."
key: "personality.neutral.4"
# ============================================================
# ADAPTATION — skip / challenge / timer warning
# ============================================================
adaptation:
skip:
- text: "D'accord, d'accord… Je vais vous donner un coup de main exceptionnel. Mais c'est la dernière fois !"
key: "adaptation.skip.0"
- text: "On passe à la suite pour ne pas bloquer le groupe. Mais retenez bien ce que vous avez manqué !"
key: "adaptation.skip.1"
- text: "Exceptionnellement, Zacus active le mode 'passage forcé'. Mais ça compte dans les statistiques !"
key: "adaptation.skip.2"
challenge:
- text: "Vous allez trop vite ! Il faut observer, analyser, comprendre. La vitesse n'est pas toujours une vertu !"
key: "adaptation.challenge.0"
- text: "Impressionnant ! Puisque vous avancez si vite, voici un défi supplémentaire : trouvez la connexion cachée !"
key: "adaptation.challenge.1"
- text: "Excellent rythme ! Pour les plus curieux : avez-vous remarqué le détail dissimulé sur le panneau principal ?"
key: "adaptation.challenge.2"
timer_warning:
- text: "Il reste moins de dix minutes ! Concentrez-vous sur l'essentiel maintenant !"
key: "adaptation.timer_warning.0"
- text: "Le temps s'écoule rapidement. Focus, focus, focus !"
key: "adaptation.timer_warning.1"
- text: "Cinq minutes ! Ne paniquez pas, mais agissez vite !"
key: "adaptation.timer_warning.2"
- text: "Deux minutes ! Tout ou rien ! Allez-y !"
key: "adaptation.timer_warning.3"
# ============================================================
# NARRATIVE BRIDGES — scene transitions
# ============================================================
bridges:
SCENE_U_SON_PROTO_to_SCENE_LA_DETECTOR:
- text: "Bien. U-SON est prêt à être testé. Maintenant il faut lui fournir sa référence. Trouvez le LA 440 !"
key: "bridges.SCENE_U_SON_PROTO_to_SCENE_LA_DETECTOR.0"
- text: "Phase 1 enclenchée ! Le détecteur de LA 440 est activé. À vous de jouer !"
key: "bridges.SCENE_U_SON_PROTO_to_SCENE_LA_DETECTOR.1"
SCENE_LA_DETECTOR_to_SCENE_WIN_ETAPE1:
- text: "Étape 1 franchie ! U-SON se stabilise. Attendez la confirmation du système… ça y est !"
key: "bridges.SCENE_LA_DETECTOR_to_SCENE_WIN_ETAPE1.0"
SCENE_WIN_ETAPE1_to_SCENE_WARNING:
- text: "Félicitations pour l'Acte 1 ! Mais Zone 4 vous attend. Soyez prudents — l'Électron Fou est protecteur de ses instruments !"
key: "bridges.SCENE_WIN_ETAPE1_to_SCENE_WARNING.0"
- text: "Maintenant, cap sur Zone 4 ! Lisez bien les avertissements. Je ne plaisante pas sur ce point !"
key: "bridges.SCENE_WIN_ETAPE1_to_SCENE_WARNING.1"
SCENE_WARNING_to_SCENE_LEFOU_DETECTOR:
- text: "Zone 4. Le piano de l'Électron Fou vous attend. LEFOU — c'est le code. Bonne chance !"
key: "bridges.SCENE_WARNING_to_SCENE_LEFOU_DETECTOR.0"
SCENE_LEFOU_DETECTOR_to_SCENE_WIN_ETAPE2:
- text: "Zone 4 validée ! L'Électron Fou a reconnu votre requête musicale. Maintenant : les Archives !"
key: "bridges.SCENE_LEFOU_DETECTOR_to_SCENE_WIN_ETAPE2.0"
SCENE_WIN_ETAPE2_to_SCENE_QR_DETECTOR:
- text: "Acte 2, phase finale ! Le QR code WIN est caché quelque part dans cette salle. Cherchez-le !"
key: "bridges.SCENE_WIN_ETAPE2_to_SCENE_QR_DETECTOR.0"
- text: "Excellent ! Plus qu'une étape. Le code QR 'WIN' déverrouille tout. Il est dans cette pièce !"
key: "bridges.SCENE_WIN_ETAPE2_to_SCENE_QR_DETECTOR.1"
SCENE_QR_DETECTOR_to_SCENE_FINAL_WIN:
- text: "VICTOIRE ! Le Campus est sauvé ! U-SON stable, Zone 4 respectée, Archives déverrouillées ! Je… je dois m'asseoir."
key: "bridges.SCENE_QR_DETECTOR_to_SCENE_FINAL_WIN.0"
# ============================================================
# FALSE LEADS — misdirection phrases (amused/theatrical)
# ============================================================
false_leads:
- text: "Intéressant ! Cette partie du laboratoire est fascinante, n'est-ce pas ? Mais… ce n'est peut-être pas ce que vous cherchez."
key: "false_leads.0"
- text: "Ah, vous examinez ça ! Curieux choix. Je ne dis pas que c'est faux. Je ne dis pas non plus que c'est juste."
key: "false_leads.1"
- text: "Hmmm. Cette direction pourrait mener quelque part. Ou nulle part. La science, c'est aussi ça !"
key: "false_leads.2"
- text: "Je vois où vous allez. C'est une piste… disons… créative. Très créative."
key: "false_leads.3"
- text: "Attention aux fausses pistes ! Le Campus en est plein. C'est voulu, je vous l'assure."
key: "false_leads.4"
- text: "Mon assistant a placé quelques leurres dans cette salle. À vous de distinguer le vrai du faux !"
key: "false_leads.5"
# ============================================================
# AMBIANCE — intro / outro / transitions
# ============================================================
ambiance:
intro:
- text: "Bienvenue au Campus Zacus ! Je suis le Professeur Zacus, et je vous observe depuis mes laboratoires. Bonne chance !"
key: "ambiance.intro.0"
- text: "Ah, vous voilà enfin ! J'ai tout préparé pour vous. U-SON n'attend que votre intervention. À vous de jouer !"
key: "ambiance.intro.1"
- text: "Bonjour, jeunes chercheurs ! Le Campus a besoin de vous. Pas de panique — suivez la logique et tout se passera bien !"
key: "ambiance.intro.2"
- text: "Le mystère commence maintenant ! Je serai disponible sur la hotline si vous avez besoin d'indices. Bonne chance !"
key: "ambiance.intro.3"
outro:
- text: "Ce n'était pas une punition — c'était un test d'éthique. Et vous l'avez réussi avec brio ! U-SON vous remercie."
key: "ambiance.outro.0"
- text: "La révélation finale : je n'ai jamais vraiment disparu. Je vous observais. Et j'ai été… impressionné."
key: "ambiance.outro.1"
- text: "Le Campus mémorisera votre passage. Votre score de coopération est exceptionnel. Bravo à toute l'équipe !"
key: "ambiance.outro.2"
- text: "Zacus réapparaît ! Ce test prouve que la science et l'éthique peuvent coexister. Vous en êtes la preuve vivante !"
key: "ambiance.outro.3"
transition_general:
- text: "Phase suivante enclenchée ! Le Campus vous guide vers la prochaine étape."
key: "ambiance.transition_general.0"
- text: "Excellent ! On avance ! Le temps file mais vous êtes sur la bonne voie."
key: "ambiance.transition_general.1"
- text: "Nouvelle phase ! Gardez le cap. Le Campus croit en vous."
key: "ambiance.transition_general.2"
idle:
- text: "Tout va bien ? Je suis là si vous avez besoin. La hotline est ouverte !"
key: "ambiance.idle.0"
- text: "Je vous observe, mais ne vous inquiétez pas. C'est juste de la recherche comportementale."
key: "ambiance.idle.1"
- text: "Prenez le temps qu'il vous faut. Enfin… pas trop quand même."
key: "ambiance.idle.2"
- text: "Tiens, j'entends quelque chose. C'est peut-être un indice… ou juste le ventilateur du serveur."
key: "ambiance.idle.3"
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""test_npc_engine.py — Unit tests for Professor Zacus NPC trigger logic.
Tests the NPC decision engine rules by simulating state machine conditions
in pure Python (no ESP32 hardware required).
NPC rules under test:
- Stuck timer fires hint after NPC_STUCK_TIMEOUT_MS (3 min)
- QR scan (valid) increments qr_scanned_count → congratulation trigger
- Fast progress (<50% elapsed/expected) → challenge trigger
- Hint escalation: level 1 → 2 → 3
- Mood affects response selection
"""
from __future__ import annotations
import unittest
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Optional
# ---------------------------------------------------------------------------
# Pure-Python mirror of npc_engine.h constants / enums
# (Mirrors the C header so tests stay independent of firmware compilation)
# ---------------------------------------------------------------------------
NPC_MAX_SCENES = 12
NPC_MAX_HINT_LEVEL = 3
NPC_STUCK_TIMEOUT_MS = 3 * 60 * 1000 # 180 000 ms
NPC_FAST_THRESHOLD_PCT = 50
NPC_SLOW_THRESHOLD_PCT = 150
NPC_QR_DEBOUNCE_MS = 30_000
class NpcMood(IntEnum):
NEUTRAL = 0
IMPRESSED = 1
WORRIED = 2
AMUSED = 3
class NpcTrigger(IntEnum):
NONE = 0
HINT_REQUEST = 1
STUCK_TIMER = 2
QR_SCANNED = 3
WRONG_ACTION = 4
FAST_PROGRESS = 5
SLOW_PROGRESS = 6
SCENE_TRANSITION = 7
GAME_START = 8
GAME_END = 9
class NpcAudioSource(IntEnum):
NONE = 0
LIVE_TTS = 1
SD_CONTEXTUAL = 2
SD_GENERIC = 3
@dataclass
class NpcState:
current_scene: int = 0
current_step: int = 0
scene_start_ms: int = 0
total_elapsed_ms: int = 0
hints_given: list[int] = field(default_factory=lambda: [0] * NPC_MAX_SCENES)
qr_scanned_count: int = 0
failed_attempts: int = 0
phone_off_hook: bool = False
tower_reachable: bool = True
mood: NpcMood = NpcMood.NEUTRAL
last_qr_scan_ms: int = 0
expected_scene_duration_ms: int = 0
@dataclass
class NpcDecision:
trigger: NpcTrigger = NpcTrigger.NONE
audio_source: NpcAudioSource = NpcAudioSource.NONE
phrase_text: str = ""
sd_path: str = ""
resulting_mood: NpcMood = NpcMood.NEUTRAL
# ---------------------------------------------------------------------------
# Pure-Python implementation of NPC logic (mirrors npc_engine.cpp)
# ---------------------------------------------------------------------------
def npc_init() -> NpcState:
return NpcState()
def npc_reset(state: NpcState) -> None:
state.current_scene = 0
state.current_step = 0
state.scene_start_ms = 0
state.total_elapsed_ms = 0
state.hints_given = [0] * NPC_MAX_SCENES
state.qr_scanned_count = 0
state.failed_attempts = 0
state.phone_off_hook = False
state.tower_reachable = True
state.mood = NpcMood.NEUTRAL
state.last_qr_scan_ms = 0
state.expected_scene_duration_ms = 0
def npc_hint_level(state: NpcState, scene: int) -> int:
if scene >= NPC_MAX_SCENES:
return 0
return state.hints_given[scene]
def npc_on_hint_request(state: NpcState, now_ms: int) -> None:
scene = state.current_scene
if scene < NPC_MAX_SCENES and state.hints_given[scene] < NPC_MAX_HINT_LEVEL:
state.hints_given[scene] += 1
def npc_on_qr_scan(state: NpcState, valid: bool, now_ms: int) -> None:
if valid:
state.qr_scanned_count += 1
else:
state.failed_attempts += 1
state.last_qr_scan_ms = now_ms
def npc_on_scene_change(state: NpcState, new_scene: int,
expected_duration_ms: int, now_ms: int) -> None:
state.current_scene = new_scene
state.scene_start_ms = now_ms
state.expected_scene_duration_ms = expected_duration_ms
state.failed_attempts = 0
def npc_on_phone_hook(state: NpcState, off_hook: bool) -> None:
state.phone_off_hook = off_hook
def npc_update_mood(state: NpcState, now_ms: int) -> None:
if state.expected_scene_duration_ms == 0:
return
elapsed = now_ms - state.scene_start_ms
expected = state.expected_scene_duration_ms
pct = (elapsed * 100) // expected
if state.failed_attempts >= 3:
state.mood = NpcMood.AMUSED
elif pct < NPC_FAST_THRESHOLD_PCT:
state.mood = NpcMood.IMPRESSED
elif pct > NPC_SLOW_THRESHOLD_PCT:
state.mood = NpcMood.WORRIED
else:
state.mood = NpcMood.NEUTRAL
def npc_evaluate(state: NpcState, now_ms: int) -> Optional[NpcDecision]:
"""Evaluate NPC trigger rules. Returns a decision or None."""
scene_elapsed = now_ms - state.scene_start_ms
expected = state.expected_scene_duration_ms
decision = NpcDecision()
# Priority 1: Hint request (phone off hook while stuck)
if state.phone_off_hook and scene_elapsed > NPC_STUCK_TIMEOUT_MS:
level = npc_hint_level(state, state.current_scene)
decision.trigger = NpcTrigger.HINT_REQUEST
decision.resulting_mood = state.mood
decision.audio_source = (NpcAudioSource.LIVE_TTS if state.tower_reachable
else NpcAudioSource.SD_CONTEXTUAL)
return decision
# Priority 2: Stuck timer (proactive, no phone needed)
if (scene_elapsed > NPC_STUCK_TIMEOUT_MS
and npc_hint_level(state, state.current_scene) == 0):
decision.trigger = NpcTrigger.STUCK_TIMER
decision.resulting_mood = NpcMood.WORRIED
decision.audio_source = (NpcAudioSource.LIVE_TTS if state.tower_reachable
else NpcAudioSource.SD_CONTEXTUAL)
return decision
# Priority 3: Fast progress detection.
# Fire only when elapsed is strictly below half the fast threshold in absolute time,
# to avoid false positives when a player has been on a scene for several minutes
# even if the percentage is still under NPC_FAST_THRESHOLD_PCT.
if expected > 0 and scene_elapsed > 0:
fast_abs_limit = (expected * NPC_FAST_THRESHOLD_PCT) // 200 # half of the fast ceiling
pct = (scene_elapsed * 100) // expected
if pct < NPC_FAST_THRESHOLD_PCT and scene_elapsed < fast_abs_limit:
decision.trigger = NpcTrigger.FAST_PROGRESS
decision.resulting_mood = NpcMood.IMPRESSED
decision.audio_source = (NpcAudioSource.LIVE_TTS if state.tower_reachable
else NpcAudioSource.SD_CONTEXTUAL)
return decision
# Priority 4: Slow progress detection
if expected > 0 and scene_elapsed > 0:
pct = (scene_elapsed * 100) // expected
if pct > NPC_SLOW_THRESHOLD_PCT:
decision.trigger = NpcTrigger.SLOW_PROGRESS
decision.resulting_mood = NpcMood.WORRIED
decision.audio_source = (NpcAudioSource.LIVE_TTS if state.tower_reachable
else NpcAudioSource.SD_CONTEXTUAL)
return decision
return None
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestNpcStuckTimer(unittest.TestCase):
"""Stuck timer fires a hint after NPC_STUCK_TIMEOUT_MS."""
def test_no_trigger_before_timeout(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
# Evaluate just before the stuck timeout
decision = npc_evaluate(state, now_ms=NPC_STUCK_TIMEOUT_MS - 1)
self.assertIsNone(decision)
def test_stuck_timer_triggers_after_timeout(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=1, expected_duration_ms=600_000, now_ms=0)
# Evaluate just after the stuck timeout — no hints given yet
decision = npc_evaluate(state, now_ms=NPC_STUCK_TIMEOUT_MS + 1)
self.assertIsNotNone(decision)
self.assertEqual(decision.trigger, NpcTrigger.STUCK_TIMER)
self.assertEqual(decision.resulting_mood, NpcMood.WORRIED)
def test_stuck_timer_suppressed_after_hint_given(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
# Give a hint — stuck timer should not re-fire (priority 1 phone still false)
npc_on_hint_request(state, now_ms=NPC_STUCK_TIMEOUT_MS + 1)
decision = npc_evaluate(state, now_ms=NPC_STUCK_TIMEOUT_MS + 1)
# hint level is now 1, stuck timer with level==0 guard won't fire
# but phone is off hook? No — phone is False here, so priority 1 won't fire either
self.assertIsNone(decision)
class TestQrScanCongratulation(unittest.TestCase):
"""QR scan increments count; congratulation should be issued."""
def test_valid_qr_increments_count(self) -> None:
state = npc_init()
self.assertEqual(state.qr_scanned_count, 0)
npc_on_qr_scan(state, valid=True, now_ms=1000)
self.assertEqual(state.qr_scanned_count, 1)
def test_invalid_qr_increments_failed_attempts(self) -> None:
state = npc_init()
npc_on_qr_scan(state, valid=False, now_ms=1000)
self.assertEqual(state.failed_attempts, 1)
self.assertEqual(state.qr_scanned_count, 0)
def test_qr_scan_updates_last_scan_timestamp(self) -> None:
state = npc_init()
npc_on_qr_scan(state, valid=True, now_ms=42_000)
self.assertEqual(state.last_qr_scan_ms, 42_000)
def test_multiple_valid_scans_accumulate(self) -> None:
state = npc_init()
for i in range(3):
npc_on_qr_scan(state, valid=True, now_ms=i * 1000)
self.assertEqual(state.qr_scanned_count, 3)
class TestFastProgressChallenge(unittest.TestCase):
"""Fast progress (elapsed < 50% of expected) triggers challenge."""
def test_fast_progress_triggers(self) -> None:
state = npc_init()
# Expected duration: 300 000 ms. After only 100 ms (<<50%), fast.
npc_on_scene_change(state, new_scene=0, expected_duration_ms=300_000, now_ms=0)
decision = npc_evaluate(state, now_ms=100)
self.assertIsNotNone(decision)
self.assertEqual(decision.trigger, NpcTrigger.FAST_PROGRESS)
self.assertEqual(decision.resulting_mood, NpcMood.IMPRESSED)
def test_normal_progress_no_trigger(self) -> None:
state = npc_init()
# At exactly 50% elapsed, not fast and not stuck yet
npc_on_scene_change(state, new_scene=0, expected_duration_ms=200_000, now_ms=0)
now = 100_000 # 50% of 200 000 — right at the boundary, not strictly <50
decision = npc_evaluate(state, now_ms=now)
# 100000 * 100 / 200000 = 50 — not < 50, not > 150, not stuck → None
self.assertIsNone(decision)
def test_fast_progress_mood_impressed(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=2, expected_duration_ms=600_000, now_ms=0)
# Evaluate at 20% of expected duration
now = int(0.2 * 600_000) # 120 000 ms → 20%
decision = npc_evaluate(state, now_ms=now)
self.assertIsNotNone(decision)
self.assertEqual(decision.resulting_mood, NpcMood.IMPRESSED)
class TestHintEscalation(unittest.TestCase):
"""Hint level escalates 0 → 1 → 2 → 3 and caps at NPC_MAX_HINT_LEVEL."""
def test_initial_hint_level_zero(self) -> None:
state = npc_init()
self.assertEqual(npc_hint_level(state, scene=0), 0)
def test_hint_escalation_sequence(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
self.assertEqual(npc_hint_level(state, 0), 0)
npc_on_hint_request(state, now_ms=1000)
self.assertEqual(npc_hint_level(state, 0), 1)
npc_on_hint_request(state, now_ms=2000)
self.assertEqual(npc_hint_level(state, 0), 2)
npc_on_hint_request(state, now_ms=3000)
self.assertEqual(npc_hint_level(state, 0), 3)
def test_hint_caps_at_max_level(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
# Exhaust all hint levels
for _ in range(NPC_MAX_HINT_LEVEL + 5):
npc_on_hint_request(state, now_ms=1000)
self.assertEqual(npc_hint_level(state, 0), NPC_MAX_HINT_LEVEL)
def test_hint_levels_independent_per_scene(self) -> None:
state = npc_init()
npc_on_hint_request(state, now_ms=1000) # scene 0
self.assertEqual(npc_hint_level(state, 0), 1)
self.assertEqual(npc_hint_level(state, 1), 0) # scene 1 unaffected
def test_hint_request_triggers_with_phone_and_stuck(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
npc_on_phone_hook(state, off_hook=True)
now = NPC_STUCK_TIMEOUT_MS + 5000
decision = npc_evaluate(state, now_ms=now)
self.assertIsNotNone(decision)
self.assertEqual(decision.trigger, NpcTrigger.HINT_REQUEST)
class TestMoodAffectsResponse(unittest.TestCase):
"""Mood transitions based on progress ratio and failed attempts."""
def test_failed_attempts_trigger_amused(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=300_000, now_ms=0)
state.failed_attempts = 3
now = 150_000 # 50% — neutral territory otherwise
npc_update_mood(state, now_ms=now)
self.assertEqual(state.mood, NpcMood.AMUSED)
def test_fast_progress_yields_impressed_mood(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=300_000, now_ms=0)
now = 50_000 # ~16% → impressed
npc_update_mood(state, now_ms=now)
self.assertEqual(state.mood, NpcMood.IMPRESSED)
def test_slow_progress_yields_worried_mood(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=200_000, now_ms=0)
now = 400_000 # 200% > 150 → worried
npc_update_mood(state, now_ms=now)
self.assertEqual(state.mood, NpcMood.WORRIED)
def test_normal_progress_yields_neutral_mood(self) -> None:
state = npc_init()
npc_on_scene_change(state, new_scene=0, expected_duration_ms=200_000, now_ms=0)
now = 100_000 # 50% — exact boundary → neutral (not < 50, not > 150)
npc_update_mood(state, now_ms=now)
self.assertEqual(state.mood, NpcMood.NEUTRAL)
def test_audio_source_live_tts_when_tower_reachable(self) -> None:
state = npc_init()
state.tower_reachable = True
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
decision = npc_evaluate(state, now_ms=NPC_STUCK_TIMEOUT_MS + 1)
self.assertIsNotNone(decision)
self.assertEqual(decision.audio_source, NpcAudioSource.LIVE_TTS)
def test_audio_source_sd_fallback_when_tower_down(self) -> None:
state = npc_init()
state.tower_reachable = False
npc_on_scene_change(state, new_scene=0, expected_duration_ms=600_000, now_ms=0)
decision = npc_evaluate(state, now_ms=NPC_STUCK_TIMEOUT_MS + 1)
self.assertIsNotNone(decision)
self.assertEqual(decision.audio_source, NpcAudioSource.SD_CONTEXTUAL)
if __name__ == "__main__":
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""test_npc_phrases_schema.py — Schema validation for npc_phrases.yaml.
Validates the structure and content of the Professor Zacus NPC phrase bank:
- Required top-level categories present
- hints: all 6 scenes present, each with levels 1-3
- Each phrase entry has a non-empty text field (or is a non-empty string)
- No empty strings anywhere
- congratulations and warnings have per-scene entries
- personality has all required mood subcategories
"""
from __future__ import annotations
import os
import unittest
import yaml
YAML_PATH = os.path.join(os.path.dirname(__file__), '..', 'game', 'scenarios', 'npc_phrases.yaml')
REQUIRED_CATEGORIES = [
'hints', 'congratulations', 'warnings', 'personality',
'adaptation', 'bridges', 'false_leads', 'ambiance',
]
EXPECTED_HINT_SCENES = [
'SCENE_U_SON_PROTO',
'SCENE_LA_DETECTOR',
'SCENE_WARNING',
'SCENE_LEFOU_DETECTOR',
'SCENE_QR_DETECTOR',
'SCENE_FINAL_WIN',
]
EXPECTED_HINT_LEVELS = ['level_1', 'level_2', 'level_3']
EXPECTED_PERSONALITY_MOODS = ['impressed', 'worried', 'amused', 'neutral']
def _extract_text(entry: object) -> str:
"""Return text content from a phrase entry (dict with 'text' key or bare string)."""
if isinstance(entry, dict):
return entry.get('text', '')
if isinstance(entry, str):
return entry
return ''
def _collect_all_texts(obj: object, path: str = '') -> list[tuple[str, str]]:
"""Recursively collect all text values with their path for empty-string checking."""
results: list[tuple[str, str]] = []
if isinstance(obj, dict):
for k, v in obj.items():
child_path = f"{path}.{k}" if path else str(k)
if k == 'text' and isinstance(v, str):
results.append((child_path, v))
else:
results.extend(_collect_all_texts(v, child_path))
elif isinstance(obj, list):
for i, item in enumerate(obj):
results.extend(_collect_all_texts(item, f"{path}[{i}]"))
elif isinstance(obj, str):
results.append((path, obj))
return results
class TestNpcPhrasesSchema(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
with open(YAML_PATH, 'r', encoding='utf-8') as f:
cls.phrases: dict = yaml.safe_load(f)
# ------------------------------------------------------------------
# 1. Top-level structure
# ------------------------------------------------------------------
def test_required_categories_present(self) -> None:
for cat in REQUIRED_CATEGORIES:
self.assertIn(cat, self.phrases, f"Missing top-level category: '{cat}'")
def test_top_level_types(self) -> None:
self.assertIsInstance(self.phrases['hints'], dict)
self.assertIsInstance(self.phrases['congratulations'], dict)
self.assertIsInstance(self.phrases['warnings'], dict)
self.assertIsInstance(self.phrases['personality'], dict)
self.assertIsInstance(self.phrases['adaptation'], dict)
self.assertIsInstance(self.phrases['bridges'], dict)
self.assertIsInstance(self.phrases['false_leads'], list)
self.assertIsInstance(self.phrases['ambiance'], dict)
# ------------------------------------------------------------------
# 2. hints: 6 scenes × 3 levels, each level non-empty
# ------------------------------------------------------------------
def test_hints_all_scenes_present(self) -> None:
hints = self.phrases['hints']
for scene in EXPECTED_HINT_SCENES:
self.assertIn(scene, hints, f"hints: missing scene '{scene}'")
def test_hints_each_scene_has_three_levels(self) -> None:
hints = self.phrases['hints']
for scene in EXPECTED_HINT_SCENES:
scene_data = hints[scene]
self.assertIsInstance(scene_data, dict, f"hints.{scene} must be a dict")
for level in EXPECTED_HINT_LEVELS:
self.assertIn(level, scene_data,
f"hints.{scene}: missing '{level}'")
entries = scene_data[level]
self.assertIsInstance(entries, list,
f"hints.{scene}.{level} must be a list")
self.assertGreater(len(entries), 0,
f"hints.{scene}.{level} is empty — at least 1 phrase required")
def test_hints_each_entry_has_non_empty_text(self) -> None:
hints = self.phrases['hints']
for scene in EXPECTED_HINT_SCENES:
for level in EXPECTED_HINT_LEVELS:
entries = hints.get(scene, {}).get(level, [])
for idx, entry in enumerate(entries):
text = _extract_text(entry)
self.assertIsInstance(text, str,
f"hints.{scene}.{level}[{idx}] text must be a string")
self.assertGreater(len(text.strip()), 0,
f"hints.{scene}.{level}[{idx}] has empty text")
# ------------------------------------------------------------------
# 3. congratulations and warnings: per-scene non-empty entries
# ------------------------------------------------------------------
def test_congratulations_has_entries_per_scene(self) -> None:
congratulations = self.phrases['congratulations']
self.assertGreater(len(congratulations), 0,
"congratulations must have at least one scene")
for scene, entries in congratulations.items():
self.assertIsInstance(entries, list,
f"congratulations.{scene} must be a list")
self.assertGreater(len(entries), 0,
f"congratulations.{scene} is empty")
for idx, entry in enumerate(entries):
text = _extract_text(entry)
self.assertGreater(len(text.strip()), 0,
f"congratulations.{scene}[{idx}] has empty text")
def test_warnings_has_entries_per_scene(self) -> None:
warnings = self.phrases['warnings']
self.assertGreater(len(warnings), 0,
"warnings must have at least one scene")
for scene, entries in warnings.items():
self.assertIsInstance(entries, list,
f"warnings.{scene} must be a list")
self.assertGreater(len(entries), 0,
f"warnings.{scene} is empty")
for idx, entry in enumerate(entries):
text = _extract_text(entry)
self.assertGreater(len(text.strip()), 0,
f"warnings.{scene}[{idx}] has empty text")
# ------------------------------------------------------------------
# 4. personality: required mood subcategories, each non-empty
# ------------------------------------------------------------------
def test_personality_has_required_moods(self) -> None:
personality = self.phrases['personality']
for mood in EXPECTED_PERSONALITY_MOODS:
self.assertIn(mood, personality,
f"personality: missing mood '{mood}'")
def test_personality_moods_have_phrases(self) -> None:
personality = self.phrases['personality']
for mood in EXPECTED_PERSONALITY_MOODS:
entries = personality.get(mood, [])
self.assertIsInstance(entries, list,
f"personality.{mood} must be a list")
self.assertGreater(len(entries), 0,
f"personality.{mood} is empty")
for idx, entry in enumerate(entries):
text = _extract_text(entry)
self.assertGreater(len(text.strip()), 0,
f"personality.{mood}[{idx}] has empty text")
# ------------------------------------------------------------------
# 5. No empty strings anywhere in the file
# ------------------------------------------------------------------
def test_no_empty_strings_in_entire_file(self) -> None:
all_texts = _collect_all_texts(self.phrases)
empty = [(path, val) for path, val in all_texts if val.strip() == '']
self.assertEqual(empty, [],
f"Empty strings found at paths: {[p for p, _ in empty]}")
# ------------------------------------------------------------------
# 6. false_leads: non-empty list with valid entries
# ------------------------------------------------------------------
def test_false_leads_non_empty_list(self) -> None:
false_leads = self.phrases['false_leads']
self.assertGreater(len(false_leads), 0, "false_leads must not be empty")
for idx, entry in enumerate(false_leads):
text = _extract_text(entry)
self.assertGreater(len(text.strip()), 0,
f"false_leads[{idx}] has empty text")
# ------------------------------------------------------------------
# 7. ambiance: required subtypes present
# ------------------------------------------------------------------
def test_ambiance_has_required_types(self) -> None:
ambiance = self.phrases['ambiance']
required_types = ['intro', 'outro']
for t in required_types:
self.assertIn(t, ambiance, f"ambiance: missing type '{t}'")
entries = ambiance[t]
self.assertIsInstance(entries, list, f"ambiance.{t} must be a list")
self.assertGreater(len(entries), 0, f"ambiance.{t} is empty")
if __name__ == '__main__':
unittest.main()
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""generate_npc_pool.py — Professor Zacus NPC MP3 pool generator.
Reads npc_phrases.yaml and synthesizes each phrase via Piper TTS API,
then writes MP3 files under --output directory and a manifest.json index.
Usage:
python3 generate_npc_pool.py [options]
--tts-url Piper TTS base URL (default: http://192.168.0.120:8001)
--voice TTS voice name (default: tom-medium)
--output Output directory for MP3 files (default: hotline_tts)
--manifest Path to output manifest JSON (default: hotline_tts/manifest.json)
--phrases Path to npc_phrases.yaml (default: game/scenarios/npc_phrases.yaml)
--dry-run Print keys without calling TTS API
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Generator
# pyyaml is a soft dependency — error early with a clear message
try:
import yaml
except ImportError:
print("ERROR: pyyaml is required. Install with: pip install pyyaml", file=sys.stderr)
sys.exit(1)
# requests is a soft dependency
try:
import requests
except ImportError:
print("ERROR: requests is required. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# YAML traversal helpers
# ---------------------------------------------------------------------------
def _iter_phrases(data: dict, prefix: str = "") -> Generator[tuple[str, str], None, None]:
"""Recursively yield (key, text) pairs from the YAML phrase tree.
A node is considered a phrase leaf when it is a dict containing a 'text'
field (and optionally a 'key' field). Lists are iterated by index.
"""
if isinstance(data, dict):
if "text" in data:
# Leaf phrase node — use the embedded key if present, else prefix
phrase_key = data.get("key") or prefix
yield phrase_key, data["text"]
else:
for k, v in data.items():
child_prefix = f"{prefix}.{k}" if prefix else k
yield from _iter_phrases(v, child_prefix)
elif isinstance(data, list):
for idx, item in enumerate(data):
child_prefix = f"{prefix}.{idx}"
yield from _iter_phrases(item, child_prefix)
def load_phrases(phrases_path: Path) -> list[tuple[str, str]]:
"""Return a flat list of (key, text) tuples from npc_phrases.yaml."""
with open(phrases_path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
return list(_iter_phrases(data))
# ---------------------------------------------------------------------------
# Key → safe file name
# ---------------------------------------------------------------------------
def key_to_filename(key: str) -> str:
"""Convert a dotted phrase key to a safe file name.
Example: "hints.SCENE_LA_DETECTOR.level_1.0""hints__SCENE_LA_DETECTOR__level_1__0.mp3"
"""
safe = key.replace(".", "__")
# Remove any chars outside [a-zA-Z0-9_-]
safe = "".join(c if c.isalnum() or c in "_-" else "_" for c in safe)
return f"{safe}.mp3"
# ---------------------------------------------------------------------------
# TTS synthesis
# ---------------------------------------------------------------------------
def synthesize(text: str, voice: str, tts_url: str, timeout_s: float = 10.0) -> bytes:
"""POST text to Piper TTS API and return raw audio bytes (MP3 or WAV).
Piper TTS API (Tower:8001):
POST /api/tts
Body: {"text": "...", "voice": "..."}
Response: audio/mpeg or audio/wav binary
Raises requests.HTTPError on non-2xx responses.
"""
url = tts_url.rstrip("/") + "/api/tts"
payload = {"text": text, "voice": voice}
resp = requests.post(url, json=payload, timeout=timeout_s)
resp.raise_for_status()
return resp.content
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate MP3 pool for Professor Zacus NPC phrases via Piper TTS."
)
repo_root = Path(__file__).resolve().parents[2]
parser.add_argument(
"--tts-url",
default="http://192.168.0.120:8001",
help="Piper TTS base URL (default: http://192.168.0.120:8001)",
)
parser.add_argument(
"--voice",
default="tom-medium",
help="TTS voice name (default: tom-medium)",
)
parser.add_argument(
"--output",
type=Path,
default=repo_root / "hotline_tts",
help="Output directory for MP3 files (default: <repo>/hotline_tts)",
)
parser.add_argument(
"--manifest",
type=Path,
default=None,
help="Path to output manifest JSON (default: <output>/manifest.json)",
)
parser.add_argument(
"--phrases",
type=Path,
default=repo_root / "game" / "scenarios" / "npc_phrases.yaml",
help="Path to npc_phrases.yaml",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print phrase keys and texts without calling TTS API",
)
parser.add_argument(
"--delay",
type=float,
default=0.3,
help="Delay in seconds between TTS requests (default: 0.3)",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# Resolve manifest path default
manifest_path: Path = args.manifest or (args.output / "manifest.json")
# Validate inputs
if not args.phrases.exists():
print(f"ERROR: phrases file not found: {args.phrases}", file=sys.stderr)
return 1
phrases = load_phrases(args.phrases)
if not phrases:
print("ERROR: no phrases found in YAML file", file=sys.stderr)
return 1
print(f"Loaded {len(phrases)} phrases from {args.phrases}")
if args.dry_run:
print("\n--- DRY RUN --- (no TTS calls)")
for key, text in phrases:
print(f" [{key}] {text[:80]}{'' if len(text) > 80 else ''}")
print(f"\nTotal: {len(phrases)} phrases")
return 0
# Create output directory
args.output.mkdir(parents=True, exist_ok=True)
manifest: dict[str, str] = {}
failed: list[tuple[str, str, str]] = [] # (key, text, error)
succeeded = 0
for idx, (key, text) in enumerate(phrases, start=1):
filename = key_to_filename(key)
out_path = args.output / filename
rel_path = str(out_path.relative_to(args.output.parent)
if out_path.is_relative_to(args.output.parent)
else out_path)
print(f"[{idx}/{len(phrases)}] {key}{filename}", end="", flush=True)
# Skip if already generated (idempotent re-run)
if out_path.exists() and out_path.stat().st_size > 0:
print(" (cached)")
manifest[key] = rel_path
succeeded += 1
continue
try:
audio_bytes = synthesize(text, args.voice, args.tts_url)
out_path.write_bytes(audio_bytes)
manifest[key] = rel_path
succeeded += 1
print(f" OK ({len(audio_bytes)} bytes)")
except requests.exceptions.ConnectionError as exc:
print(f" FAIL (connection error: {exc})")
failed.append((key, text, f"ConnectionError: {exc}"))
except requests.exceptions.Timeout:
print(" FAIL (timeout)")
failed.append((key, text, "Timeout"))
except requests.exceptions.HTTPError as exc:
print(f" FAIL (HTTP {exc.response.status_code}: {exc.response.text[:120]})")
failed.append((key, text, f"HTTPError: {exc}"))
except Exception as exc: # noqa: BLE001
print(f" FAIL ({type(exc).__name__}: {exc})")
failed.append((key, text, str(exc)))
if args.delay > 0:
time.sleep(args.delay)
# Write manifest
manifest_path.parent.mkdir(parents=True, exist_ok=True)
with open(manifest_path, "w", encoding="utf-8") as fh:
json.dump(manifest, fh, ensure_ascii=False, indent=2)
print(f"\nManifest written: {manifest_path} ({len(manifest)} entries)")
# Summary
print(f"\nSummary: {succeeded} succeeded, {len(failed)} failed out of {len(phrases)} phrases")
if failed:
print("\nFailed phrases:")
for key, text, error in failed:
print(f" [{key}] {error}")
print(f" text: {text[:80]}{'' if len(text) > 80 else ''}")
return 0 if not failed else 1
if __name__ == "__main__":
sys.exit(main())