feat: AI integration — voice pipeline, hints engine, MCP server, analytics, security

- Voice pipeline: ESP32 WebSocket client → voice bridge → LLM → Piper TTS (Tower :8001)
- Hints engine: 3 puzzles (LA_440, LEFOU_PIANO, QR_FINALE), anti-cheat, 3 hint levels
- MCP hardware server: 6 tools (puzzle, audio, LED, camera, scenario, status), stdio transport
- Analytics: ESP32 module + 6 web endpoints + Dashboard UI with chat interface
- Security: auth middleware (Bearer NVS), rate limiting, input validation on 30 endpoints
- Frontend: code-split (1.1MB → 210KB initial), ErrorBoundary, API timeout, WS reconnect
- Tests: 24 Python + 38 TypeScript + 18 MCP = 80 project tests (+ 19 mascarade)
- Specs: AI_INTEGRATION_SPEC, MCP_HARDWARE_SERVER_SPEC, QA_TEST_MATRIX_SPEC
- Docs: SECURITY, DEPLOYMENT_RUNBOOK, voice pipeline guide, AI architecture map
- 6 AI agent definitions (.github/agents/ai_*.md)
- TUI orchestration script (tools/dev/zacus_tui.py)
- Docker compose TTS for Tower + KXKM-AI
- CHANGELOG, README, mkdocs.yml updated
- Cycle detection (DFS) in runtime3 validator
- Sprint plan: plans/SPRINT_AI_INTEGRATION.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-22 13:52:45 +01:00
parent c2fc583bae
commit 20aed903ba
135 changed files with 11028 additions and 5989 deletions
+4 -5
View File
@@ -4,17 +4,17 @@
Final alignment tasks that ensure every agent contract, runbook, and onboarding doc stays in sync before a major phase or hand-off. Final alignment tasks that ensure every agent contract, runbook, and onboarding doc stays in sync before a major phase or hand-off.
## Do ## Do
- Review `docs/AGENT_TODO.md`, `docs/TEST_SCRIPT_COORDINATOR.md`, and `docs/AGENTS_INDEX.md` to confirm the current state of gates, artifacts, and command registries. - Review `hardware/firmware/docs/AGENT_TODO.md`, `docs/TEST_SCRIPT_COORDINATOR.md`, and `docs/AGENTS_INDEX.md` to confirm the current state of gates, artifacts, and command registries.
- Ensure every folder-specific `AGENTS*.md` entry (global, firmware, tools, docs, etc.) matches the latest instructions in `.github/agents` briefs; note mismatches in the release log and update the relevant doc. - Ensure every folder-specific `AGENTS*.md` entry (global, firmware, tools, docs, etc.) matches the latest instructions in `.github/agents` briefs; note mismatches in the release log and update the relevant doc.
- Check that onboarding materials (`docs/QUICKSTART.md`, `docs/_generated/COCKPIT_COMMANDS.md`) reflect the expected workflows referenced by the new gate or automation plan. - Check that onboarding materials (`docs/QUICKSTART.md`, `docs/_generated/COCKPIT_COMMANDS.md`) reflect the expected workflows referenced by the new gate or automation plan.
- Record each alignment review, gate status and artifact path in `GIT_WRITE_OPS_FINAL_REPORT.md` or `docs/AGENT_TODO.md` for traceability. - Record each alignment review, gate status and artifact path in `GIT_WRITE_OPS_FINAL_REPORT.md` or `hardware/firmware/docs/AGENT_TODO.md` for traceability.
## Must Not ## Must Not
- Deliver feature changes in this pass; the goal is coherence and evidence before launch. - Deliver feature changes in this pass; the goal is coherence and evidence before launch.
- Skip the safety checkpoint or artifact-tracking mandate from `AGENTS.md`. - Skip the safety checkpoint or artifact-tracking mandate from `AGENTS.md`.
## References ## References
- `docs/AGENT_TODO.md` - `hardware/firmware/docs/AGENT_TODO.md`
- `docs/TEST_SCRIPT_COORDINATOR.md` - `docs/TEST_SCRIPT_COORDINATOR.md`
- `docs/AGENTS_INDEX.md` - `docs/AGENTS_INDEX.md`
- `.github/agents/*.md` - `.github/agents/*.md`
@@ -24,7 +24,6 @@ Final alignment tasks that ensure every agent contract, runbook, and onboarding
- run: python3 tools/dev/gen_cockpit_docs.py - run: python3 tools/dev/gen_cockpit_docs.py
- run: git status -sb - run: git status -sb
2. Confirmer la cohérence des AGENT contracts et docs. 2. Confirmer la cohérence des AGENT contracts et docs.
- run: rg -n 'AGENT' docs/AGENT_TODO.md - run: rg -n 'AGENT' hardware/firmware/docs/AGENT_TODO.md
3. Capturer les artefacts/étapes dans les rapports. 3. Capturer les artefacts/étapes dans les rapports.
- run: cat GIT_WRITE_OPS_FINAL_REPORT.md - run: cat GIT_WRITE_OPS_FINAL_REPORT.md
+7 -1
View File
@@ -13,11 +13,17 @@ Every `.github/agents/*.md` file is a focused briefing for Copilot/GUIs; chaque
| `docs.md` | Docs/onboarding : updates concises, liens valides, structure préservée. | | `docs.md` | Docs/onboarding : updates concises, liens valides, structure préservée. |
| `kit.md` | GM kit station/export, lien jeu/printables, pas de renommage sans synchro. | | `kit.md` | GM kit station/export, lien jeu/printables, pas de renommage sans synchro. |
| `ci.md` | Workflows/templates : edits minimaux, CI impact reporté. | | `ci.md` | Workflows/templates : edits minimaux, CI impact reporté. |
| `firmware_core.md` | Firmware tree complet : PlatformIO builds, `docs/AGENT_TODO.md`, logs/artifacts hors git. | | `firmware_core.md` | Firmware tree complet : PlatformIO builds, `hardware/firmware/docs/AGENT_TODO.md`, logs/artifacts hors git. |
| `firmware_tooling.md` | `tools/dev/` helpers : `--help`, grep-friendly logs, ports/timeouts configurables. | | `firmware_tooling.md` | `tools/dev/` helpers : `--help`, grep-friendly logs, ports/timeouts configurables. |
| `firmware_copilot.md` | Copilot-specific firmware duties (UI Link, LittleFS, I2S, artefact tracking, sprint/runbook refs). | | `firmware_copilot.md` | Copilot-specific firmware duties (UI Link, LittleFS, I2S, artefact tracking, sprint/runbook refs). |
| `firmware_tests.md` | Smoke/stress scripts + artifact metadata (`meta.json`, `commands.txt`, `summary.md`) et reporting. | | `firmware_tests.md` | Smoke/stress scripts + artifact metadata (`meta.json`, `commands.txt`, `summary.md`) et reporting. |
| `firmware_docs.md` | Firmware docs/onboarding updates, command index regen, runbook mentions. | | `firmware_docs.md` | Firmware docs/onboarding updates, command index regen, runbook mentions. |
| `ai_voice.md` | ESP-SR wake word + commandes vocales françaises, pipeline I2S → WakeNet → MultiNet → mascarade. |
| `ai_tts.md` | TTS serveur (Coqui XTTS-v2 / Piper), clonage voix Professeur Zacus, streaming audio ESP32. |
| `ai_vision.md` | Détection objets on-device (ESP-DL / ESPDet-Pico), comptage joueurs, reconnaissance props. |
| `ai_hints.md` | Indices adaptatifs LLM, personnalité NPC Professeur Zacus, anti-triche, analytics. |
| `ai_audio_gen.md` | Musique ambiante générative (AudioCraft MusicGen), SFX, soundscapes par salle. |
| `ai_mcp.md` | Serveur MCP hardware : bridge LLM → ESP32 via JSON-RPC 2.0, discovery, auth. |
| `ALIGNMENT_COMPLETE.md` | Checklist de pré-lancement : AGENT contracts, runbooks, onboarding, artefacts. | | `ALIGNMENT_COMPLETE.md` | Checklist de pré-lancement : AGENT contracts, runbooks, onboarding, artefacts. |
| `PHASE_LAUNCH_PLAN.md` | Checkpoint de lancement de phase : gates, artefacts, reporting RC/AGENT_TODO. | | `PHASE_LAUNCH_PLAN.md` | Checkpoint de lancement de phase : gates, artefacts, reporting RC/AGENT_TODO. |
+3 -4
View File
@@ -6,9 +6,9 @@ Execution plan for gating, artifacts, and reporting required to open a new phase
## Do ## Do
- Define the gate list early: include the PlatformIO matrix, smoke/stress scripts, scenario/audio/printables validators, and any additional QC scripts noted in `docs/TEST_SCRIPT_COORDINATOR.md`. - Define the gate list early: include the PlatformIO matrix, smoke/stress scripts, scenario/audio/printables validators, and any additional QC scripts noted in `docs/TEST_SCRIPT_COORDINATOR.md`.
- Capture every artifact/log path under `artifacts/` and `hardware/firmware/logs/`, writing their metadata (`meta.json`, `commands.txt`, `summary.md`) before closing the phase. - Capture every artifact/log path under `artifacts/` and `hardware/firmware/logs/`, writing their metadata (`meta.json`, `commands.txt`, `summary.md`) before closing the phase.
- Mention UI Link, WebSocket, HTTP, and I2S health verdicts in `docs/AGENT_TODO.md` and `docs/TEST_SCRIPT_COORDINATOR.md` along with the `artifacts/<phase>` references. - Mention UI Link, WebSocket, HTTP, and I2S health verdicts in `hardware/firmware/docs/AGENT_TODO.md` and `docs/TEST_SCRIPT_COORDINATOR.md` along with the `artifacts/<phase>` references.
- Summarize the phase status in `docs/RC_FINAL_REPORT_TEMPLATE.md` (if applicable) and note any blockers in `docs/RC_AUTOFIX_CICD.md`. - Summarize the phase status in `docs/RC_FINAL_REPORT_TEMPLATE.md` (if applicable) and note any blockers in `docs/RC_AUTOFIX_CICD.md`.
- Capture the gate/artifact summary and verification commands in `GIT_WRITE_OPS_FINAL_REPORT.md` or `docs/AGENT_TODO.md` so the phase status is documented. - Capture the gate/artifact summary and verification commands in `GIT_WRITE_OPS_FINAL_REPORT.md` or `hardware/firmware/docs/AGENT_TODO.md` so the phase status is documented.
## Must Not ## Must Not
- Leave gates undocumented or skip the regression checks listed in the root `AGENTS.md` or `hardware/firmware/AGENTS.md`. - Leave gates undocumented or skip the regression checks listed in the root `AGENTS.md` or `hardware/firmware/AGENTS.md`.
@@ -16,7 +16,7 @@ Execution plan for gating, artifacts, and reporting required to open a new phase
## References ## References
- `docs/TEST_SCRIPT_COORDINATOR.md` - `docs/TEST_SCRIPT_COORDINATOR.md`
- `docs/AGENT_TODO.md` - `hardware/firmware/docs/AGENT_TODO.md`
- `docs/RC_FINAL_REPORT_TEMPLATE.md` - `docs/RC_FINAL_REPORT_TEMPLATE.md`
- `docs/RC_AUTOFIX_CICD.md` - `docs/RC_AUTOFIX_CICD.md`
@@ -28,4 +28,3 @@ Execution plan for gating, artifacts, and reporting required to open a new phase
2. Documenter artefacts/logs + verdicts réseau. 2. Documenter artefacts/logs + verdicts réseau.
- run: python3 tools/test/audit_coherence.py - run: python3 tools/test/audit_coherence.py
- run: cat GIT_WRITE_OPS_FINAL_REPORT.md - run: cat GIT_WRITE_OPS_FINAL_REPORT.md
+40
View File
@@ -0,0 +1,40 @@
# Custom Agent AI Generative Audio
## Scope
Dynamic ambient music generation, SFX creation, and per-room soundscapes driven by game state.
## Technologies
- AudioCraft MusicGen (Meta), Stable Audio Open
- KXKM-AI RTX 4090 for GPU inference
- PCM/Opus streaming to ESP32 audio system
## Do
- Build a prompt library mapping room/puzzle states to audio mood descriptors.
- Implement generation pipeline: prompt → MusicGen → normalize → cache → stream.
- Cache generated clips by prompt hash to avoid redundant GPU work.
- Expose REST API: `POST /audio/generate` (prompt → clip), `GET /audio/stream/:id`.
- Integrate with game state engine for automatic soundscape transitions.
## Must Not
- Generate clips longer than 60 s per request (GPU memory guard).
- Commit generated audio to git; use object storage or Docker volumes.
## Dependencies
- KXKM-AI RTX 4090 — GPU inference for MusicGen / Stable Audio.
- ESP32 audio system — I2S DAC output, buffer management, volume control.
## Test Gates
- Generation time < 10 s for a 30 s clip on RTX 4090.
- Audio quality MOS > 3.5 (mean opinion score on 20-sample listening test).
## References
- AudioCraft: https://github.com/facebookresearch/audiocraft
- Stable Audio Open: https://github.com/Stability-AI/stable-audio-tools
## Plan d'action
1. Démarrer le service de génération audio sur KXKM-AI.
- run: ssh kxkm@kxkm-ai 'cd /data/zacus-audio && docker compose up -d musicgen'
2. Mesurer le temps de génération sur un clip de 30 s.
- run: python3 tools/ai/audio_gen_bench.py --duration 30 --max-time 10
3. Vérifier le cache et le streaming vers l'ESP32.
- run: curl -s http://kxkm-ai:5600/audio/generate -d '{"prompt":"mysterious lab ambient","duration":30}'
+41
View File
@@ -0,0 +1,41 @@
# Custom Agent AI Adaptive Hints
## Scope
Dynamic hint generation, difficulty adaptation, and NPC Professor Zacus personality via LLM.
## Technologies
- mascarade API (LLM orchestration layer)
- LLM backends: Qwen2.5-Coder (local), Claude (cloud fallback)
- Conversation memory (Graphiti / context window)
## Do
- Design prompt templates for Professor Zacus NPC persona (curious, cryptic, encouraging).
- Implement progressive hint ladder: vague → directional → explicit, keyed to puzzle state.
- Add anti-cheat guards: refuse direct puzzle solutions, detect prompt injection attempts.
- Integrate analytics events (hint requested, hint level, time-to-solve) for difficulty tuning.
- Validate hint quality via human evaluation rubric.
## Must Not
- Leak full puzzle solutions in any hint tier.
- Bypass mascarade API auth or rate limits.
- Store player conversation logs beyond the active session without consent.
## Dependencies
- mascarade API — LLM routing, model selection, conversation memory.
- Analytics engine — event ingestion for hint/difficulty metrics.
## Test Gates
- Hint relevance > 90% (human eval on 50-sample test set).
- Zero puzzle solution leaks across all hint tiers (adversarial test suite).
## References
- mascarade API: `/Users/electron/mascarade`
- `game/prompts/` — prompt template sources
## Plan d'action
1. Valider les templates de prompts contre le scénario actif.
- run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
2. Lancer la suite de tests anti-triche.
- run: python3 tools/ai/hint_adversarial_test.py --no-leak-tolerance
3. Évaluer la pertinence des indices sur le jeu de test.
- run: python3 tools/ai/hint_relevance_eval.py --samples 50 --threshold 0.90
+41
View File
@@ -0,0 +1,41 @@
# Custom Agent AI MCP Hardware Server
## Scope
MCP server bridging LLM tool calls to ESP32 hardware actions (lights, motors, sensors, locks).
## Technologies
- MCP protocol (Model Context Protocol), JSON-RPC 2.0
- mascarade MCP client infrastructure
- ESP32 web API (HTTP + WebSocket)
## Do
- Define MCP tool schemas for each hardware action (e.g., `set_light`, `read_sensor`, `unlock_door`).
- Implement JSON-RPC 2.0 transport (stdio + HTTP/SSE for remote).
- Add device discovery via mDNS or static registry.
- Enforce auth tokens between mascarade and MCP server.
- Return structured results with status codes and sensor readings.
## Must Not
- Expose hardware tools without authentication (token or mutual TLS).
- Allow unbounded concurrent tool calls to the same device (serialize per-device).
- Commit auth tokens or secrets to git.
## Dependencies
- mascarade MCP infrastructure — client registration, tool routing.
- ESP32 web API — HTTP endpoints on each device for hardware control.
## Test Gates
- Tool call round-trip latency < 500 ms (mascarade → MCP server → ESP32 → response).
- 100% success rate on all defined tools against a live or mock device.
## References
- MCP specification: https://modelcontextprotocol.io
- mascarade MCP client: `/Users/electron/mascarade/core/mcp/`
## Plan d'action
1. Valider le schéma des outils MCP.
- run: python3 tools/ai/mcp_schema_validate.py --schema mcp/tools.json
2. Tester la latence aller-retour sur un device mock.
- run: python3 tools/ai/mcp_latency_bench.py --target mock --max-latency 500
3. Vérifier l'authentification et la découverte des devices.
- run: python3 tools/ai/mcp_auth_test.py --require-token
+40
View File
@@ -0,0 +1,40 @@
# Custom Agent AI TTS / Voice Cloning
## Scope
Server-side text-to-speech, Professor Zacus voice cloning, and audio streaming to ESP32 devices.
## Technologies
- Coqui XTTS-v2 (voice cloning), Piper TTS (fast fallback)
- Docker deployment on mascarade stack
- PCM/Opus streaming over HTTP chunked transfer
## Do
- Prepare and curate voice samples for Professor Zacus persona (≥ 30 s clean audio).
- Create Docker Compose service (`zacus-tts`) integrated with mascarade stack.
- Expose REST API: `POST /tts/generate` (text → audio), `POST /tts/stream` (chunked).
- Implement audio format conversion (WAV → PCM 16-bit 16 kHz) for ESP32 I2S playback.
- Cache frequently used phrases to reduce GPU load.
## Must Not
- Store voice samples in git; keep them in object storage or Docker volumes.
- Bypass mascarade auth on the TTS API endpoints.
## Dependencies
- mascarade Docker stack — networking, auth, service registry.
- ESP32 audio system — I2S DAC output and buffer management.
## Test Gates
- Latency < 2 s for a 10-word sentence (first token to last byte).
- Voice similarity > 80% (speaker verification cosine similarity).
## References
- Coqui XTTS-v2: https://github.com/coqui-ai/TTS
- Piper TTS: https://github.com/rhasspy/piper
## Plan d'action
1. Construire et démarrer le service TTS Docker.
- run: docker compose -f docker-compose.ai.yml up -d zacus-tts
2. Vérifier la latence de génération.
- run: curl -w '%{time_total}' -X POST http://localhost:5500/tts/generate -d '{"text":"Bonjour explorateurs"}'
3. Valider la similarité vocale sur les échantillons de référence.
- run: python3 tools/ai/tts_similarity_bench.py --threshold 0.80
+40
View File
@@ -0,0 +1,40 @@
# Custom Agent AI Vision
## Scope
On-device object detection, player counting, and puzzle prop recognition on ESP32 camera modules.
## Technologies
- ESP-DL v3.2, ESPDet-Pico (lightweight detector)
- ESP-WHO (face/person detection framework)
- KXKM-AI (RTX 4090) for model training and quantization
## Do
- Train custom ESPDet-Pico model on puzzle prop dataset (cards, tokens, keys).
- Integrate ESP32-CAM capture pipeline with detection inference loop.
- Expose detection results via local JSON API for puzzle trigger hooks.
- Quantize models to INT8 for ESP32-S3 deployment (PSRAM-aware).
- Maintain a labeled dataset under `data/vision/` with version tags.
## Must Not
- Stream raw camera frames off-device unless debugging (bandwidth + privacy).
- Commit model weights to git; store in releases or object storage.
## Dependencies
- ESP32-CAM hardware — OV2640/OV5640 sensor, PSRAM.
- KXKM-AI node — GPU training and INT8 quantization pipeline.
## Test Gates
- Detection throughput > 7 FPS on ESP32-S3 with PSRAM.
- Accuracy > 85% mAP on the prop test set.
## References
- ESP-DL: https://github.com/espressif/esp-dl
- ESP-WHO: https://github.com/espressif/esp-who
## Plan d'action
1. Lancer l'entraînement sur KXKM-AI.
- run: ssh kxkm@kxkm-ai 'cd /data/zacus-vision && python3 train_espdet.py --epochs 50'
2. Quantiser le modèle en INT8.
- run: python3 tools/ai/quantize_model.py --format esp-dl --precision int8
3. Valider le FPS et la précision sur le firmware.
- run: python3 tools/dev/vision_bench.py --min-fps 7 --min-map 0.85
+39
View File
@@ -0,0 +1,39 @@
# Custom Agent AI Voice (ESP-SR)
## Scope
Wake word detection, speech command recognition, and voice pipeline between ESP32 and server (mascarade bridge).
## Technologies
- ESP-SR v2.0, WakeNet, MultiNet, AFE (Acoustic Front-End)
- I2S microphone input, mascarade REST API
## Do
- Configure custom wake word ("Professeur Zacus") via WakeNet training tools.
- Implement French command vocabulary in MultiNet with game-relevant phrases.
- Wire I2S mic input through AFE → WakeNet → MultiNet pipeline on ESP32.
- Bridge recognized commands to mascarade API for LLM processing.
- Maintain detection thresholds and noise-gate parameters in a tuneable config header.
## Must Not
- Ship raw audio to server unless explicitly requested (privacy + bandwidth).
- Modify firmware I2S driver outside `hardware/firmware/` conventions; coordinate with firmware_core agent.
## Dependencies
- `firmware_core.md` — ESP32 build chain and I2S driver.
- mascarade API — command relay and LLM orchestration.
## Test Gates
- Wake word detection rate > 95% at 1 m distance, ambient < 50 dB.
- French command accuracy > 90% on the defined vocabulary set.
## References
- Espressif ESP-SR documentation: https://github.com/espressif/esp-sr
- `hardware/firmware/AGENTS.md`
## Plan d'action
1. Valider le build ESP-SR avec WakeNet activé.
- run: bash hardware/firmware/build_all.sh
2. Tester la détection du wake word sur le banc de test.
- run: python3 tools/dev/wake_word_bench.py --threshold 0.95 --distance 1m
3. Mesurer la précision des commandes françaises.
- run: python3 tools/dev/command_accuracy.py --lang fr --min-accuracy 0.90
+5 -6
View File
@@ -4,8 +4,8 @@
`audio/manifests/**` and derived `audio/generated/**` outputs. `audio/manifests/**` and derived `audio/generated/**` outputs.
## Do ## Do
- Run `python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml` after edits. - Run `python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml` after edits.
- Refresh exports with `python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml` when manifest references change. - Refresh exports with `python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml` when manifest references change.
## Must Not ## Must Not
- Regenerate binary audio assets unless explicitly requested. - Regenerate binary audio assets unless explicitly requested.
@@ -16,8 +16,7 @@
## Plan daction ## Plan daction
1. Valider les manifestes audio et exporter les scénarios. 1. Valider les manifestes audio et exporter les scénarios.
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml - run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
- run: python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml - run: python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
2. Relever les IDs utilisés pour éviter les divergences. 2. Relever les IDs utilisés pour éviter les divergences.
- run: rg -n 'id:' audio/manifests/zacus_v1_audio.yaml - run: rg -n 'id:' audio/manifests/zacus_v2_audio.yaml
+1 -2
View File
@@ -6,7 +6,7 @@ All files under `hardware/firmware/**`.
## Do ## Do
- Follow `hardware/firmware/AGENTS.md`: keep build/smoke gates in `tools/dev`, track structural changes in docs, and update `hardware/firmware/docs/AGENT_TODO.md` with build/smoke state. - Follow `hardware/firmware/AGENTS.md`: keep build/smoke gates in `tools/dev`, track structural changes in docs, and update `hardware/firmware/docs/AGENT_TODO.md` with build/smoke state.
- Run PlatformIO builds via `./build_all.sh` or the individual matrix before major changes. - Run PlatformIO builds via `./build_all.sh` or the individual matrix before major changes.
- Document UI Link, LittleFS, and I2S status in `docs/AGENT_TODO.md` along with artifact guidance. - Document UI Link, LittleFS, and I2S status in `hardware/firmware/docs/AGENT_TODO.md` along with artifact guidance.
## Must Not ## Must Not
- Commit logs or artifacts; keep them under `hardware/firmware/logs/` or `artifacts/` and mention their paths in reports. - Commit logs or artifacts; keep them under `hardware/firmware/logs/` or `artifacts/` and mention their paths in reports.
@@ -22,4 +22,3 @@ All files under `hardware/firmware/**`.
2. Noter les statuts UI Link/LittleFS/I2S et loggers. 2. Noter les statuts UI Link/LittleFS/I2S et loggers.
- run: bash hardware/firmware/tools/dev/run_smoke_tests.sh - run: bash hardware/firmware/tools/dev/run_smoke_tests.sh
- run: python3 hardware/firmware/tools/dev/serial_smoke.py --role auto --wait-port 3 --allow-no-hardware - run: python3 hardware/firmware/tools/dev/serial_smoke.py --role auto --wait-port 3 --allow-no-hardware
+1 -1
View File
@@ -9,7 +9,7 @@
- Keep scripts non-interactive when possible and surface a short, grep-friendly summary. - Keep scripts non-interactive when possible and surface a short, grep-friendly summary.
## Must Not ## Must Not
- Skip recording logs/commands in `hardware/firmware/logs/` or the runbook (`docs/AGENT_TODO.md`). - Skip recording logs/commands in `hardware/firmware/logs/` or the runbook (`hardware/firmware/docs/AGENT_TODO.md`).
- Hardcode static port/device names that would break on other machines. - Hardcode static port/device names that would break on other machines.
## References ## References
+6 -6
View File
@@ -5,7 +5,7 @@
## Do ## Do
- Treat `game/scenarios/*.yaml` as the single source of truth for story points and content. - Treat `game/scenarios/*.yaml` as the single source of truth for story points and content.
- Run `python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml` and `python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml` after edits. - Run `python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml` and `python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml` after edits.
- Re-run audio/printable manifest validators when scenario IDs or references change. - Re-run audio/printable manifest validators when scenario IDs or references change.
## Must Not ## Must Not
@@ -17,9 +17,9 @@
## Plan daction ## Plan daction
1. Valider le scénario source et régénérer les docs. 1. Valider le scénario source et régénérer les docs.
- run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml - run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
- run: python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml - run: python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
- run: python3 tools/scenario/verify_runtime3_pivots.py game/scenarios/zacus_v2.yaml
2. Revalider les manifestes audio et printables après toute mise à jour. 2. Revalider les manifestes audio et printables après toute mise à jour.
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml - run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml - run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
+9 -11
View File
@@ -12,8 +12,8 @@ Entire repository; project manager + tech lead + QA gatekeeper as per `AGENTS.md
- Touch licensing text or use destructive git commands without explicit requests. - Touch licensing text or use destructive git commands without explicit requests.
## Gates ## Gates
- PlatformIO matrix (`pio run -e esp32dev`, `esp32_release`, `esp8266_oled`, `ui_rp2040_ili9488`, `ui_rp2040_ili9486`). - PlatformIO release gates (`pio run -d hardware/firmware -e freenove_esp32s3`, `pio run -d hardware/firmware -e esp8266_oled`).
- Scenario/audio/printables validators from `docs/AGENTS_INDEX.md`. - Scenario/audio/printables validators plus Runtime 3 checks from `Makefile` and `tools/test/run_content_checks.sh`.
## References ## References
- `AGENTS.md` - `AGENTS.md`
@@ -24,13 +24,11 @@ Entire repository; project manager + tech lead + QA gatekeeper as per `AGENTS.md
- run: git status -sb - run: git status -sb
- run: git diff --stat - run: git diff --stat
2. Exécuter la matrice PlatformIO complète via PlatformIO. 2. Exécuter la matrice PlatformIO complète via PlatformIO.
- run: pio run -e esp32dev - run: pio run -d hardware/firmware -e freenove_esp32s3
- run: pio run -e esp32_release - run: pio run -d hardware/firmware -e esp8266_oled
- run: pio run -e esp8266_oled
- run: pio run -e ui_rp2040_ili9488
- run: pio run -e ui_rp2040_ili9486
3. Valider les scénarios et manifestes croisés. 3. Valider les scénarios et manifestes croisés.
- run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml - run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
- run: python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml - run: python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml - run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml - run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
- run: python3 tools/scenario/verify_runtime3_pivots.py game/scenarios/zacus_v2.yaml
+2 -3
View File
@@ -5,7 +5,7 @@
## Do ## Do
- Validate stations via `rg --files kit-maitre-du-jeu` and `rg -n "station|indice|enigme|zacus" kit-maitre-du-jeu`. - Validate stations via `rg --files kit-maitre-du-jeu` and `rg -n "station|indice|enigme|zacus" kit-maitre-du-jeu`.
- Run `python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml` when updates touch exported assets. - Run `python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml` when updates touch exported assets.
## Must Not ## Must Not
- Rename station identifiers without syncing references across game and printables. - Rename station identifiers without syncing references across game and printables.
@@ -19,5 +19,4 @@
- run: rg --files kit-maitre-du-jeu - run: rg --files kit-maitre-du-jeu
- run: rg -n 'station|indice|enigme|zacus' kit-maitre-du-jeu - run: rg -n 'station|indice|enigme|zacus' kit-maitre-du-jeu
2. Valider tout asset imprimable référencé. 2. Valider tout asset imprimable référencé.
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml - run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
+3 -4
View File
@@ -4,7 +4,7 @@
`printables/manifests/**` and `printables/src/**`. `printables/manifests/**` and `printables/src/**`.
## Do ## Do
- Validate `printables/manifests/zacus_v1_printables.yaml` via `python3 tools/printables/validate_manifest.py` after updates. - Validate `printables/manifests/zacus_v2_printables.yaml` via `python3 tools/printables/validate_manifest.py` after updates.
- Preserve deterministic naming and export references for every asset. - Preserve deterministic naming and export references for every asset.
## Must Not ## Must Not
@@ -16,7 +16,6 @@
## Plan daction ## Plan daction
1. Valider le manifeste printables. 1. Valider le manifeste printables.
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml - run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
2. Refaire les exports synchronisés si nécessaire (RG). 2. Refaire les exports synchronisés si nécessaire (RG).
- run: rg -n 'file:' printables/manifests/zacus_v1_printables.yaml - run: rg -n 'file:' printables/manifests/zacus_v2_printables.yaml
+15
View File
@@ -1,6 +1,21 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Ajouté
- Runtime 3 : compilateur (`tools/scenario/compile_runtime3.py`) et simulateur (`tools/scenario/simulate_runtime3.py`) pour le moteur de scénarios V2.
- Studio visuel React + Blockly dans `frontend-scratch-v2/` pour l'édition graphique des scénarios.
- Schéma Scenario V2 : refonte du format YAML, validation renforcée et pivot vers `game/scenarios/zacus_v2.yaml` comme source canonique.
- Analyse d'intégration IA : `docs/AI_INTEGRATION_ANALYSIS.md` — specs, opportunités et roadmap.
- Documentation : cartes d'architecture (system-map, component-map, data-flow-map, feature-map, migration-map, agent-matrix, release-map), specs de déploiement, runbook opérationnel.
- Spécifications de durcissement sécurité.
- Cibles Makefile : `runtime3-compile`, `runtime3-simulate`, `runtime3-verify`, `runtime3-test`, `runtime3-firmware-bundle`.
### Modifié
- Consolidation du dépôt : suppression des doublons (`AGENTS 2.md`, `AGENT_TODO 2.md`).
- Sous-module `ESP32_ZACUS` mis à jour (23 commits).
### Précédemment non versionné
- Workflow : nouvelle exportation `tools/scenario/export_md.py` et briefs Markdown (kit + `docs/_generated/SCENARIO_BRIEF.md`) alignés sur `game/scenarios/zacus_v2.yaml`. - Workflow : nouvelle exportation `tools/scenario/export_md.py` et briefs Markdown (kit + `docs/_generated/SCENARIO_BRIEF.md`) alignés sur `game/scenarios/zacus_v2.yaml`.
- Printables : manifeste `printables/manifests/zacus_v2_printables.yaml`, prompts dédiés pour chaque asset et `tools/printables/validate_manifest.py` pour éviter les trous entre IDs et fichiers. - Printables : manifeste `printables/manifests/zacus_v2_printables.yaml`, prompts dédiés pour chaque asset et `tools/printables/validate_manifest.py` pour éviter les trous entre IDs et fichiers.
- Documentation : AGENTS, WORKFLOWS, GLOSSARY, Quickstart, index et le Makefile rappellent que le YAML est la single source of truth et listent les commandes standard de validation/export. - Documentation : AGENTS, WORKFLOWS, GLOSSARY, Quickstart, index et le Makefile rappellent que le YAML est la single source of truth et listent les commandes standard de validation/export.
Submodule ESP32_ZACUS updated: db7083845b...24aad99533
+48 -4
View File
@@ -1,9 +1,17 @@
PYTHON ?= python3 PYTHON ?= python3
SCENARIO ?= game/scenarios/zacus_v2.yaml
FRONTEND_DIR ?= frontend-scratch-v2
.PHONY: scenario-validate audio-validate printables-validate export all-validate images .PHONY: bootstrap-validators bootstrap-docs scenario-validate audio-validate printables-validate export validate-runtime-bundle content-checks runtime3-compile runtime3-simulate runtime3-verify runtime3-test runtime3-firmware-bundle frontend-lint frontend-test frontend-build docs-build docs-serve all-validate images
bootstrap-validators:
bash tools/setup/install_validators.sh
bootstrap-docs:
$(PYTHON) -m pip install --user --break-system-packages -r tools/requirements/docs.txt
scenario-validate: scenario-validate:
$(PYTHON) tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml $(PYTHON) tools/scenario/validate_scenario.py $(SCENARIO)
audio-validate: audio-validate:
$(PYTHON) tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml $(PYTHON) tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
@@ -12,9 +20,45 @@ printables-validate:
$(PYTHON) tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml $(PYTHON) tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
export: export:
$(PYTHON) tools/scenario/export_md.py game/scenarios/zacus_v2.yaml $(PYTHON) tools/scenario/export_md.py $(SCENARIO)
all-validate: scenario-validate audio-validate printables-validate validate-runtime-bundle:
$(PYTHON) tools/scenario/validate_runtime_bundle.py
content-checks:
bash tools/test/run_content_checks.sh
runtime3-compile:
$(PYTHON) tools/scenario/compile_runtime3.py $(SCENARIO)
runtime3-simulate:
$(PYTHON) tools/scenario/simulate_runtime3.py $(SCENARIO)
runtime3-verify:
$(PYTHON) tools/scenario/verify_runtime3_pivots.py $(SCENARIO)
runtime3-test:
$(PYTHON) -m unittest discover -s tests/runtime3 -p 'test_*.py'
runtime3-firmware-bundle:
$(PYTHON) tools/scenario/export_runtime3_firmware_bundle.py $(SCENARIO)
frontend-lint:
cd $(FRONTEND_DIR) && npm run lint
frontend-test:
cd $(FRONTEND_DIR) && npm run test
frontend-build:
cd $(FRONTEND_DIR) && npm run build
docs-build:
$(PYTHON) -m mkdocs build --strict
docs-serve:
$(PYTHON) -m mkdocs serve
all-validate: scenario-validate audio-validate printables-validate validate-runtime-bundle runtime3-compile runtime3-simulate runtime3-verify runtime3-test
@echo "All validations passed." @echo "All validations passed."
images: images:
+71 -134
View File
@@ -1,163 +1,100 @@
# Le Mystere du Professeur Zacus
# 🤖🎩 Le Mystère du Professeur Zacus 🎩🤖 Zacus est en refonte vers un produit hybride unique:
- un jeu terrain fiable sur carte Freenove ESP32-S3,
- un studio auteur moderne en React + Blockly,
- un runtime portable "Zacus Runtime 3" compile depuis le YAML canonique.
![Couverture](./assets/cover.png) ## Canon actuel
- Source narrative: `game/scenarios/zacus_v2.yaml`
- Studio auteur: `frontend-scratch-v2/`
- Runtime portable: `tools/scenario/compile_runtime3.py` + `tools/scenario/simulate_runtime3.py`
- Cible hardware principale: `hardware/firmware` avec `freenove_esp32s3`
- Plans et memoire: `memory/`, `plans/`, `todos/`
- Architecture et cartes Mermaid: `docs/architecture/`
> **Bienvenue dans lenquête la plus instable du multivers** : indices imprimables, audio, modules électroniques intégrés, et un guide MJ qui ne bug jamais (sauf si tu limprimes en 3D). ## Démarrage rapide
>
> **⚛ L’électron rare — ⚡ unstable by design**
>
> **Auteur : Clément SAILLANT**
[![Build](https://img.shields.io/badge/build-validate%20%2B%20export-brightgreen)](./.github/workflows/validate.yml) ### 1. Bootstrap validation
[![PlatformIO](https://img.shields.io/badge/PlatformIO-ready-brightgreen)](https://platformio.org/)
[![Code: MIT](https://img.shields.io/badge/code-MIT-blue)](./LICENSE)
[![Content: CC%20BY--NC%204.0](https://img.shields.io/badge/content-CC%20BY--NC%204.0-orange)](./LICENSE-CONTENT.md)
## ⚡ Pitch (30 secondes chrono)
Le Professeur Zacus a disparu. Son labo est sous tension : **signaux audio**, **capsules dindices**, **preuves imprimées**… et un dispositif électronique indispensable qui réagit à chaque étape de lenquête.
Les joueurs fouillent, recoupent, déduisent — comme une vraie équipe denquête, mais avec plus de blagues et moins de panique.
Le MJ déroule une session fluide, avec des checkpoints et une fin satisfaisante (sauf si tu oublies le gâteau).
> *"Si tu trouves une LED qui clignote, cest normal. Si elle te parle, cest probablement un bug... ou le MJ qui sennuie."*
---
## ✅ Ce que tu obtiens (concret, pas du vent)
- **Printables** prêts à imprimer (indices, cartes, accessoires)
- **Guide Maître du Jeu** (mise en place, script, solutions)
- **Audio** (timers / ambiance / déclenchements)
- **Scénario YAML** = source de vérité (durée/difficulté modulables)
- **Option électronique** : ESP32/Arduino (UI, effets, interactions)
> Tout est pensé pour être **rejouable** et **facile à préparer**. Même pour les MJ qui nont jamais touché un oscilloscope.
> *"MJ : Maître du Jeu, mais aussi Maître du Jazz, Maître du Jenga, Maître du Juste Prix... à toi de choisir."*
---
## 🕹️ Pour qui / durée / niveau de fun
- **Joueurs** : 614 (recommandé), ou équipes de 24
- **Durée** : 105 min (45 + 60, modulable selon le groupe)
- **Âge** : famille / anniversaire (adaptable, sauf pour les robots)
- **Matériel** : imprimante + modules électroniques (ESP32 + écran / interface tactile) pour piloter les phases de jeu
> *"Le mode slow motion est réservé aux anniversaires avec trop de bonbons."*
---
## 🎬 Démo
![Démo](./assets/demo.gif)
> *"Si tu croises un QR code dans les archives, scanne-le. Si tu croises un QR code sur ton gâteau, scanne-le aussi (on ne sait jamais)."*
---
## 🧠 Comment ça marche (en 1 minute, ou 42 secondes si tu es pressé)
### Source de vérité : le scénario YAML
Le scénario principal est dans `game/scenarios/`. Il pilote :
- les étapes / stations,
- les validations / codes,
- les exports (briefs MJ, docs, manifestes).
### Pipeline du repo
`game/scenarios/*.yaml -> tools/ (validate + export) -> kit MJ / printables / audio -> hardware/firmware/`
![Diagramme](./assets/diagram.png)
---
## 🧩 Démarrage rapide (MJ, version turbo)
> *"Si tu veux une expérience vraiment futuriste, branche un ESP32 sur ton chat. (Non, ne fais pas ça, mais lidée est marrante.)"*
1. Lis le **guide MJ** : `kit-maitre-du-jeu/`
2. Imprime les **printables** : `printables/`
3. Prépare laudio : `audio/`
4. Prépare, câble et flashe l’électronique : `hardware/` (un kit esp32-S3 avec écran est requis pour chaque partie)
5. Lance la partie 🎩
👉 FAQ / dépannage : `docs/faq.md` (aucune question idiote, sauf “où est le MJ ?”)
---
## 🛠️ Démarrage rapide (dev, version quantum)
> *"Astuce dev : Si tu valides le YAML sans erreur du premier coup, tu gagnes un badge Zacus Quantum (à imprimer toi-même)."*
Installer les validateurs :
```bash ```bash
bash tools/setup/install_validators.sh bash tools/setup/install_validators.sh
bash tools/test/run_content_checks.sh
``` ```
Valider le scénario officiel : ### 2. Compiler et simuler Runtime 3
```bash ```bash
python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml
python3 tools/scenario/simulate_runtime3.py game/scenarios/zacus_v2.yaml
python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml
``` ```
Exporter un brief Markdown : ### 3. Démarrer le studio React + Blockly
```bash
python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml -o docs/exports/zacus_v2.md
```
Frontend canon (V2) :
```bash ```bash
cd frontend-scratch-v2 cd frontend-scratch-v2
npm install npm install
npm test
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
``` ```
--- ### 4. Utiliser le shell canonique
```bash
## 🧾 Contenu du dépôt (repères galactiques) ./tools/dev/zacus.sh content-checks
./tools/dev/zacus.sh runtime3-compile
> *"Ce dépôt est plus organisé que le bureau du Professeur Zacus (et ça, cest pas peu dire)."* make runtime3-verify
make runtime3-test
```text ./tools/dev/zacus.sh frontend-test
game/scenarios/ Scénarios YAML (source de vérité) ./tools/dev/zacus.sh frontend-build
kit-maitre-du-jeu/ Guide MJ + solutions + script ./tools/dev/zacus.sh menu
printables/ Cartes/indices + manifestes
audio/ Manifestes audio + ressources
tools/ Validation + export + génération
hardware/ Firmware & accessoires électroniques (prérequis)
docs/ Mini-site / FAQ / ressources
``` ```
--- ## Cartographie du dépôt
- `game/`: scénarios YAML canoniques.
- `audio/`: manifestes audio et assets associés.
- `printables/`: manifestes et exports imprimables.
- `kit-maitre-du-jeu/`: matériel MJ et déroulé terrain.
- `frontend-scratch-v2/`: studio auteur React + Blockly.
- `hardware/firmware/`: firmware, APIs device, scripts terrain.
- `tools/`: validateurs, compilateur/simulateur Runtime 3, shells d'automatisation.
- `docs/`: quickstart, architecture, benchmark OSS et runbooks.
- `memory/`, `plans/`, `todos/`: pilotage de la refonte.
## 🧷 Visuels ## AI Integration
> *"Si tu reconnais le prototype sur la photo, tu es officiellement un expert en électronique de fête."* Le projet intègre une couche IA pour enrichir l'expérience terrain :
- **Voice pipeline** : wake word (ESP-SR) → ASR → LLM → TTS (Piper / XTTS-v2) → speaker. Scaffold prêt, voir [`docs/voice/VOICE_PIPELINE_GUIDE.md`](docs/voice/VOICE_PIPELINE_GUIDE.md).
- **Vision** : détection d'objets via ESP-DL pour indices contextuels.
- **LLM hints** : le Professeur Zacus répond aux joueurs via mascarade.
- **TUI dev** : script interactif d'orchestration → `python3 tools/dev/zacus_tui.py`
![Prototype](./assets/board.png) Analyse complète : [`docs/AI_INTEGRATION_ANALYSIS.md`](docs/AI_INTEGRATION_ANALYSIS.md)
![Printables](./assets/printables.png) ## Sécurité & Déploiement
--- - [`docs/SECURITY.md`](docs/SECURITY.md) — audit firmware, HMAC auth, rate limiting
- [`docs/DEPLOYMENT_RUNBOOK.md`](docs/DEPLOYMENT_RUNBOOK.md) — procédures de déploiement terrain
## 🧑‍🎓 Licence ## Statut du projet
- **Code** : MIT (`LICENSE`) - Runtime 3 : compilateur + simulateur + export firmware bundle OK
- **Contenu créatif** (scénarios, docs, printables, assets) : CC BYNC 4.0 (`LICENSE-CONTENT.md`) - Studio auteur : React 19 + Blockly, 18 tests passing
- Firmware : sécurité P0 intégrée (HMAC, rate limit, safe OTA)
- Voice pipeline : scaffold ESP-SR prêt, TTS Docker validé
- Specs : `ZACUS_RUNTIME_3_SPEC.md`, `STORY_DESIGNER_SCRATCH_LIKE_SPEC.md`
## Documentation à lire
- `docs/QUICKSTART.md`
- `docs/architecture/index.md`
- `docs/AI_INTEGRATION_ANALYSIS.md`
- `specs/ZACUS_RUNTIME_3_SPEC.md`
- `specs/STORY_DESIGNER_SCRATCH_LIKE_SPEC.md`
- `docs/benchmark-oss.md`
> *"Ce jeu est garanti sans IA malveillante, mais avec des enfants qui peuvent hacker le scénario à tout moment."* ## Notes de refonte
--- - Le YAML reste la source de vérité pendant la migration.
- Le Runtime 3 devient le contrat portable entre studio, simulateur et firmware.
- `hardware/firmware/esp32/` reste en lecture seule.
- Les chemins legacy ne doivent être supprimés qu'après preuve de remplacement.
## 🤝 Crédits ## Licences
- Code: MIT (`LICENSE`)
> *"Merci à tous les MJ, enfants, parents, et robots qui ont testé ce scénario. Mention spéciale à ceux qui ont trouvé le twist avant la LED rouge."* - Contenu créatif: CC BY-NC 4.0 (`LICENSE-CONTENT.md`)
**Auteur : Clément SAILLANT**
Signature : **⚛ L’électron rare** — **⚡ unstable by design**
> *"Ce projet a été validé par un oscilloscope, un grille-pain, et une IA qui adore les énigmes."*
---
OpenGraph : `assets/og.png` (1200×630)
- generation/story-ia : scénarios IA
+3 -3
View File
@@ -4,7 +4,7 @@
Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboarding / briefing files (`.github/agents/`, `docs/_generated/`). Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboarding / briefing files (`.github/agents/`, `docs/_generated/`).
## Doit ## Doit
- Avant toute mise à jour, lire `docs/AGENT_TODO.md` pour ne pas dupliquer les efforts de coordination. - Avant toute mise à jour, lire `hardware/firmware/docs/AGENT_TODO.md` pour ne pas dupliquer les efforts de coordination.
- Refléter tout changement dorganisation ou de procédure dans `docs/TEST_SCRIPT_COORDINATOR.md`, `docs/TEST_COHERENCE_AUDIT_RUNBOOK.md`, et les AGENTS concernés. - Refléter tout changement dorganisation ou de procédure dans `docs/TEST_SCRIPT_COORDINATOR.md`, `docs/TEST_COHERENCE_AUDIT_RUNBOOK.md`, et les AGENTS concernés.
- Maintenir `docs/_generated/COCKPIT_COMMANDS.md` en cohérence avec `tools/dev/cockpit_commands.yaml` via `python3 tools/dev/gen_cockpit_docs.py`. - Maintenir `docs/_generated/COCKPIT_COMMANDS.md` en cohérence avec `tools/dev/cockpit_commands.yaml` via `python3 tools/dev/gen_cockpit_docs.py`.
- Documenter les nouveaux artefacts (gates/tests) dans la section adéquate du runbook et mentionner les chemins dans le reporting template. - Documenter les nouveaux artefacts (gates/tests) dans la section adéquate du runbook et mentionner les chemins dans le reporting template.
@@ -12,7 +12,7 @@ Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboar
## Artefacts ## Artefacts
- Les docs nintègrent pas dartefacts binaires ; citez plutôt les dossiers `artifacts/` lorsque vous décrivez un protocole ou un gate réussi. - Les docs nintègrent pas dartefacts binaires ; citez plutôt les dossiers `artifacts/` lorsque vous décrivez un protocole ou un gate réussi.
- Pour les instructions “TODO”, mettez à jour `docs/AGENT_TODO.md` (bloc 4/5) en parallèle des docs. - Pour les instructions “TODO”, mettez à jour `hardware/firmware/docs/AGENT_TODO.md` (bloc 4/5) en parallèle des docs.
## Bonnes pratiques ## Bonnes pratiques
- Chaque mise à jour de runbook ou guide doit inclure un rappel sur la nécessité de respecter `docs/AGENT_TODO.md` comme tracker unique. - Chaque mise à jour de runbook ou guide doit inclure un rappel sur la nécessité de respecter `hardware/firmware/docs/AGENT_TODO.md` comme tracker unique.
+5 -5
View File
@@ -4,15 +4,15 @@
Firmware agents cover `esp32_audio/`, `ui/esp8266_oled/`, `ui/rp2040_tft/`, and shared `protocol/` code that drives the MCU binaries. Firmware agents cover `esp32_audio/`, `ui/esp8266_oled/`, `ui/rp2040_tft/`, and shared `protocol/` code that drives the MCU binaries.
## Doit ## Doit
- Lire puis mettre à jour `docs/AGENT_TODO.md` avant dagir (le tracker unique garde la trace de l’état de chaque gate). - Lire puis mettre à jour `hardware/firmware/docs/AGENT_TODO.md` avant dagir (le tracker unique garde la trace de l’état de chaque gate).
- Respecter le contrat global (`AGENTS.md`) et les règles doutillage de `tools/dev/AGENTS.md` (gates/scripts centralisés, artefacts hors git). - Respecter le contrat global (`AGENTS.md`) et les règles doutillage de `tools/dev/AGENTS.md` (gates/scripts centralisés, artefacts hors git).
- Sassurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `docs/AGENT_TODO.md`. - Sassurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `hardware/firmware/docs/AGENT_TODO.md`.
- Pour les modifications de structure, ajouter un commentaire dans `docs/AGENT_TODO.md` + mentionner le commit dans `docs/TEST_SCRIPT_COORDINATOR.md`. - Pour les modifications de structure, ajouter un commentaire dans `hardware/firmware/docs/AGENT_TODO.md` + mentionner le commit dans `docs/TEST_SCRIPT_COORDINATOR.md`.
- Toujours signaler l’état des UI Link / LittleFS / I2S dans `docs/AGENT_TODO.md:6-14` lorsquon manipule ces composants. - Toujours signaler l’état des UI Link / LittleFS / I2S dans `hardware/firmware/docs/AGENT_TODO.md:6-14` lorsquon manipule ces composants.
## Reporting ## Reporting
- Chaque run doit produire des artefacts `artifacts/<phase>/<timestamp>` ; les chemins doivent figurer dans la case correspondante du TODO. - Chaque run doit produire des artefacts `artifacts/<phase>/<timestamp>` ; les chemins doivent figurer dans la case correspondante du TODO.
- Données hardware (logs, ports) restent hors git : référencer les fichiers dans `docs/AGENT_TODO.md` ou le rapport final. - Données hardware (logs, ports) restent hors git : référencer les fichiers dans `hardware/firmware/docs/AGENT_TODO.md` ou le rapport final.
## References ## References
- Branche principale : `docs/SPRINT_RECOMMENDATIONS.md`, `README.md`, `protocol/ui_link_v2.md` - Branche principale : `docs/SPRINT_RECOMMENDATIONS.md`, `README.md`, `protocol/ui_link_v2.md`
+3 -3
View File
@@ -4,15 +4,15 @@
Tests agents focus on files under `tools/dev/`, `tools/test/`, `esp32_audio/tests/`, and the artifacts/log directories (`artifacts/`, `logs/`). Tests agents focus on files under `tools/dev/`, `tools/test/`, `esp32_audio/tests/`, and the artifacts/log directories (`artifacts/`, `logs/`).
## Doit ## Doit
- Toujours lire/mettre à jour `docs/AGENT_TODO.md` pour annoncer les gates en cours ou bloqués (ex. smoke, stress, audit). - Toujours lire/mettre à jour `hardware/firmware/docs/AGENT_TODO.md` pour annoncer les gates en cours ou bloqués (ex. smoke, stress, audit).
- Exécuter les calibrations/gates via les scripts officiels (`tools/dev/run_matrix_and_smoke.sh`, `tools/dev/run_smoke_tests.sh`, `tools/dev/run_stress_tests.py`, `tools/test/audit_coherence.py`), puis consigner les artefacts sous `artifacts/`. - Exécuter les calibrations/gates via les scripts officiels (`tools/dev/run_matrix_and_smoke.sh`, `tools/dev/run_smoke_tests.sh`, `tools/dev/run_stress_tests.py`, `tools/test/audit_coherence.py`), puis consigner les artefacts sous `artifacts/`.
- Tenir `docs/TEST_SCRIPT_COORDINATOR.md` à jour avec les résultats et les chemins d’évidence (section “Exécutions récentes” + reporting template). - Tenir `docs/TEST_SCRIPT_COORDINATOR.md` à jour avec les résultats et les chemins d’évidence (section “Exécutions récentes” + reporting template).
- Valider les commandes cockpit via `tools/dev/cockpit_commands.yaml` et `docs/_generated/COCKPIT_COMMANDS.md` (regénération avec `python3 tools/dev/gen_cockpit_docs.py` si besoin). - Valider les commandes cockpit via `tools/dev/cockpit_commands.yaml` et `docs/_generated/COCKPIT_COMMANDS.md` (regénération avec `python3 tools/dev/gen_cockpit_docs.py` si besoin).
- Mentionner les échecs réseau (HTTP/WebSocket/WiFi) dans `docs/AGENT_TODO.md` et `docs/TEST_SCRIPT_COORDINATOR.md` pour la coordination. - Mentionner les échecs réseau (HTTP/WebSocket/WiFi) dans `hardware/firmware/docs/AGENT_TODO.md` et `docs/TEST_SCRIPT_COORDINATOR.md` pour la coordination.
## Artefacts ## Artefacts
- Les logs exigent `meta.json`, `git.txt`, `commands.txt`, `summary.md` (au minimum) ; vérifiez les répertoires demandés dans `docs/TEST_SCRIPT_COORDINATOR.md`. - Les logs exigent `meta.json`, `git.txt`, `commands.txt`, `summary.md` (au minimum) ; vérifiez les répertoires demandés dans `docs/TEST_SCRIPT_COORDINATOR.md`.
- Pas de commit dartefacts ; les chemins doivent être mentionnés dans le TODO. - Pas de commit dartefacts ; les chemins doivent être mentionnés dans le TODO.
## Bonnes pratiques ## Bonnes pratiques
- En cas de panne persistante (UI link, stress panic, scénario manquant), capturez les traces dans `artifacts/rc_live/<timestamp>` et notez la prochaine action dans `docs/AGENT_TODO.md`. - En cas de panne persistante (UI link, stress panic, scénario manquant), capturez les traces dans `artifacts/rc_live/<timestamp>` et notez la prochaine action dans `hardware/firmware/docs/AGENT_TODO.md`.
+235
View File
@@ -0,0 +1,235 @@
# Analyse IA & Intégration — Le Mystere du Professeur Zacus
> Generee le 2026-03-21 par analyse exhaustive (firmware, frontend, tooling, docs, web research)
---
## 1. SWOT — Firmware ESP32-S3
### Forces
- Architecture modulaire (audio/UI/network/scenario managers)
- Gestion memoire PSRAM mature (caps_allocator, fallback chains)
- Audio I2S avec protection underrun, DMA async
- LVGL avec DMA flush async, SIMD optionnel
- Runtime 3 step-based avec transitions event-driven
### Faiblesses (CRITIQUES)
| ID | Severite | Issue | Fichier |
|----|----------|-------|---------|
| FW-01 | CRITICAL | Credentials WiFi en dur | storage_manager.cpp:73 |
| FW-02 | CRITICAL | API web sans authentification | main.cpp:5932-5960 |
| FW-03 | HIGH | Watchdog timeout (calculator eval) | main.cpp + platformio.ini |
| FW-04 | HIGH | Pas de validation input API | main.cpp:5945-5950 |
| FW-05 | HIGH | Pas de rate limiting | main.cpp:5200-5960 |
| FW-06 | HIGH | Pas de timeout JSON parsing | main.cpp |
| FW-07 | MEDIUM | LVGL fragmentation (54KB pool) | platformio.ini:80 |
| FW-08 | MEDIUM | Audio underrun sans recovery | audio_manager.cpp:407-418 |
| FW-09 | MEDIUM | Buffer overflow string ops | ui_manager.cpp:145 |
| FW-10 | MEDIUM | Pas de HTTPS/TLS | main.cpp:5966 |
### Opportunites
- OTA firmware updates (partition scheme compatible)
- Secure Boot + Flash encryption (ESP32-S3 natif)
- Auth middleware centralise pour webOnApi()
- Watchdog supervisor software
---
## 2. SWOT — Frontend React+Blockly
### Forces
- Architecture composants clean (4 onglets)
- API client complet (30+ endpoints, dual protocol)
- Blockly bidirectionnel (workspace <-> YAML)
- TypeScript strict + Zod validation
- Accessibilite (aria-label, aria-live)
### Faiblesses
| ID | Severite | Issue | Fichier |
|----|----------|-------|---------|
| FE-01 | HIGH | Zero tests (0% coverage) | — |
| FE-02 | HIGH | Pas de React ErrorBoundary | App.tsx |
| FE-03 | HIGH | Pas de timeout API requests | api.ts:21-34 |
| FE-04 | MEDIUM | Blockly registration globale mutable | BlocklyDesigner.tsx:35-86 |
| FE-05 | MEDIUM | Pas de reconnexion WebSocket | api.ts:274-289 |
| FE-06 | MEDIUM | Bundle bloat (Blockly+Monaco ~2.5MB) | package.json |
| FE-07 | LOW | Tab state non persiste | App.tsx:22 |
| FE-08 | LOW | Pas de dark mode | App.css |
---
## 3. SWOT — Python Tooling
### Forces
- Pipeline clair (compile -> simulate -> validate -> export)
- Validation semantique comprehensive
- Simulation deterministe avec detection cycles (max_steps)
- Shell scripts robustes (set -euo pipefail)
### Faiblesses
| ID | Severite | Issue | Fichier |
|----|----------|-------|---------|
| PY-01 | HIGH | Seulement 5 tests (pas de negatifs) | test_runtime3_routes.py |
| PY-02 | HIGH | Pas de detection cycles transitions | runtime3_common.py:227-233 |
| PY-03 | MEDIUM | Schema version hard-codee (v1 only) | runtime3_common.py:196 |
| PY-04 | MEDIUM | normalize_token() fallback silencieux | runtime3_common.py:31-33 |
| PY-05 | LOW | Pas de TypedDict/dataclass partout | runtime3_common.py |
---
## 4. Documentation — Etat
| Zone | Completude | Action |
|------|-----------|--------|
| Architecture (8 maps) | 100% | A jour |
| Specifications (13 specs) | 90% | 3 specs critiques manquantes |
| Getting Started | 95% | OK |
| Operations | 30% | Runbook manquant |
| Securite | 10% | Stub seulement |
| Tests/QA | 40% | Pas de matrice unifiee |
### Specs MANQUANTES
1. `DEPLOYMENT_RUNBOOK.md` — procedures terrain
2. `SECURITY.md` — modele auth, menaces, remediations
3. `MCP_HARDWARE_SERVER_SPEC.md` — integration mascarade MCP
4. `ANALYTICS_OBSERVABILITY_SPEC.md` — telemetrie temps reel
5. `QA_TEST_MATRIX_SPEC.md` — matrice de tests formelle
6. `NETWORK_TOPOLOGY_SPEC.md` — ESP-NOW format messages
### Fichiers OBSOLETES a supprimer
- `docs/AGENTS 2.md`, `docs/AGENT_TODO 2.md` (duplicates)
- `docs/AGENTS_DOCS.md`, `docs/AGENTS_FIRMWARE.md` (remplace par .github/agents/)
- `docs/GENERER_UN_SCENARIO_STORY_V2.md` (references obsoletes)
---
## 5. Etat de l'Art IA 2026 — Opportunites d'Integration
### TOP 5 Technologies Prioritaires
| # | Technologie | Usage Zacus | Maturite | Licence |
|---|------------|-------------|----------|---------|
| 1 | **ESP-SR v2.0** (Espressif) | Wake word "Hey Zacus" + commandes vocales offline (300 mots) | Production | Espressif |
| 2 | **Coqui XTTS-v2** | Cloner la voix du Prof Zacus (6s sample) pour narration dynamique | Production | MPL-2.0 |
| 3 | **ESP-DL v3.2** | Detection objets on-device (YOLOv11n, 7 FPS) pour puzzles physiques | Production | MIT |
| 4 | **ESP RainMaker MCP** | Controle materiel via LLM ("allume la lampe UV salle 3") | Production | Apache 2.0 |
| 5 | **AudioCraft MusicGen** | Musique ambiante generative par salle/puzzle sur KXKM-AI | Production | MIT/CC-BY-NC |
### Projets de Reference
| Projet | Stars | Pertinence | URL |
|--------|-------|-----------|-----|
| **XiaoZhi ESP32** | 25k+ | Architecture quasi-identique (ESP32-S3 + wake + LLM + TTS via MCP) | github.com/78/xiaozhi-esp32 |
| **Willow** | — | Pipeline voix ESP32-S3 <500ms latence | github.com/HeyWillow/willow |
| **ClueControl** | — | Puzzles Arduino escape room (RFID, maglocks) | github.com/ClueControl |
| **EscapeRoom (devlinb)** | — | Backend Node.js anti-prompt-injection pour hints IA | github.com/devlinb/escaperoom |
| **IoT-MCP (Duke)** | — | Framework MCP pour IoT, 205ms latence, 74KB RAM | github.com/Duke-CEI-Center/IoT-MCP-Servers |
### Architecture IA Cible
```mermaid
flowchart TD
subgraph ESP32-S3["ESP32-S3 (On-Device)"]
SR[ESP-SR v2.0<br/>Wake Word + Commands]
DL[ESP-DL v3.2<br/>Object Detection]
CAM[Camera OV2640]
MIC[Microphone I2S]
SPK[Speaker I2S]
end
subgraph Server["Serveur mascarade"]
LLM[LLM via mascarade API<br/>Hints adaptatifs]
TTS[Coqui XTTS-v2<br/>Voix Prof Zacus]
MCP[MCP Server<br/>Hardware Control]
ANALYTICS[Analytics Engine<br/>Difficulte adaptative]
end
subgraph KXKM["KXKM-AI (RTX 4090)"]
MUSIC[AudioCraft MusicGen<br/>Ambient + SFX]
TRAIN[Fine-tune modeles<br/>voix/detection custom]
end
MIC --> SR
CAM --> DL
SR -->|"commande vocale"| MCP
DL -->|"objet detecte"| MCP
MCP -->|"action puzzle"| ESP32-S3
MCP <-->|"API mascarade"| LLM
LLM -->|"hint text"| TTS
TTS -->|"audio stream"| SPK
MUSIC -->|"ambient MP3"| SPK
ESP32-S3 -->|"telemetrie"| ANALYTICS
ANALYTICS -->|"ajuster difficulte"| LLM
```
---
## 6. Plan d'Integration IA — Phases
### Phase A: Fondations Securite (P0 — 1-2 semaines)
1. Supprimer credentials WiFi en dur → NVS + provisioning QR
2. Ajouter auth Bearer token sur tous les endpoints API
3. Input validation + rate limiting
4. Augmenter LVGL pool 54→96KB
5. Augmenter stack Arduino 16→24KB
### Phase B: Voice Pipeline (P1 — 2-4 semaines)
1. Integrer ESP-SR v2.0 pour wake word "Hey Zacus"
2. Deployer Coqui XTTS-v2 en Docker sur VM mascarade
3. Pipeline: ESP32 mic → WiFi stream → mascarade → LLM → TTS → ESP32 speaker
4. Commandes vocales offline (MultiNet, 50 mots FR)
5. Ref: XiaoZhi ESP32 architecture
### Phase C: Vision & Detection (P1 — 2-4 semaines)
1. Integrer ESP-DL v3.2 pour detection objets puzzle
2. Entrainer modele custom (props specifiques Zacus)
3. Face detection pour comptage joueurs (ESP-WHO)
4. Au-dela du QR basique: detection indices physiques
### Phase D: LLM Hints Adaptatifs (P2 — 4-6 semaines)
1. API mascarade comme backend LLM pour hints contextuels
2. Prompt engineering anti-triche (ref: devlinb/escaperoom)
3. Analytics temps reel → ajustement difficulte
4. Prof Zacus comme NPC LLM avec memoire conversation
### Phase E: Audio Generatif (P2 — 2-3 semaines)
1. AudioCraft MusicGen sur KXKM-AI (RTX 4090)
2. Generation ambiante par salle/puzzle
3. SFX dynamiques via Stable Audio Open
4. Streaming vers ESP32 speakers
### Phase F: MCP & Orchestration (P3 — 4-6 semaines)
1. MCP server hardware (ESP RainMaker MCP pattern)
2. Integration mascarade MCP existant
3. Controle naturel-language de tous les peripheriques
4. Dashboard game master temps reel
---
## 7. Corrections Prioritaires Code
### Immediate (cette semaine)
```
FW-01: storage_manager.cpp — NVS credentials
FW-02: main.cpp — Bearer auth middleware
FW-03: platformio.ini — stack 16→24KB
FE-02: App.tsx — ErrorBoundary wrapper
FE-03: api.ts — timeout 5s defaut
```
### Court terme (2 semaines)
```
FW-04-06: main.cpp — input validation, rate limit, JSON timeout
FW-07: platformio.ini — LVGL pool 54→96KB
PY-01: tests — 5→25+ tests avec negatifs
PY-02: runtime3_common.py — detection cycles
FE-01: frontend — premiers tests Vitest
```
### Moyen terme (1 mois)
```
FW-08-10: audio recovery, string safety, TLS
FE-04-06: Blockly cleanup, WS reconnect, bundle split
PY-03-05: schema migration, TypedDict, normalize warnings
DOCS: specs manquantes + cleanup obsoletes
```
+446
View File
@@ -0,0 +1,446 @@
# Deployment Runbook
## Status
- State: draft
- Date: 2026-03-21
- Audience: Game master, field technician
- Related: `docs/QUICKSTART.md`, `docs/SECURITY.md`
---
## 1) Pre-Deployment Checklist
### Hardware
- [ ] ESP32-S3 Freenove board powered and accessible via USB
- [ ] Speaker connected to I2S output (verified with test tone)
- [ ] LED strips connected and addressed (WS2812B data pin)
- [ ] Camera OV2640 connected (if vision features enabled)
- [ ] Microphone INMP441 connected (if voice features enabled)
- [ ] All puzzle actuators (servos, relays) wired and tested
- [ ] Backup ESP32 board available on-site
### Network
- [ ] WiFi access point configured and powered
- [ ] SSID and password documented (not the default)
- [ ] ESP32 IP address reserved (DHCP reservation or static)
- [ ] mascarade server reachable from game WiFi (if AI features)
- [ ] Game master device (laptop/tablet) on same network
### Content
- [ ] Scenario YAML validated: `bash tools/test/run_content_checks.sh`
- [ ] Runtime 3 IR compiled: `python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml`
- [ ] Runtime 3 simulation passed: `python3 tools/scenario/simulate_runtime3.py game/scenarios/zacus_v2.yaml`
- [ ] Audio files present in `hardware/firmware/data/audio/`
- [ ] All audio files referenced in scenario exist on filesystem
- [ ] Printable materials generated and printed
### Security
- [ ] API bearer token generated and stored in NVS
- [ ] WiFi credentials stored in NVS (not hardcoded)
- [ ] `.env` file configured on server with matching token
- [ ] Serial debug disabled in production firmware
- [ ] Review `docs/SECURITY.md` checklist
---
## 2) Firmware Flash Procedure
### 2.1 Prerequisites
```bash
# Install PlatformIO CLI (if not installed)
pip install platformio
# Verify toolchain
pio platform update espressif32
```
### 2.2 Build Firmware
```bash
cd hardware/firmware
# Build for Freenove ESP32-S3
pio run -e freenove_esp32s3
# Build for ESP8266 OLED (secondary display)
pio run -e esp8266_oled
```
### 2.3 Flash Firmware
```bash
# Connect ESP32-S3 via USB, identify port
ls /dev/tty.usb* # macOS
ls /dev/ttyUSB* # Linux
# Flash firmware + bootloader + partition table
pio run -e freenove_esp32s3 -t upload --upload-port /dev/tty.usbmodem*
# Monitor serial output to verify boot
pio device monitor -e freenove_esp32s3 --port /dev/tty.usbmodem* -b 115200
```
### 2.4 Expected Boot Output
```
[INFO] Zacus Runtime 3 v3.1
[INFO] Free heap: 245000 bytes
[INFO] PSRAM: 8388608 bytes
[INFO] WiFi connecting to: EscapeRoom-Net
[INFO] WiFi connected, IP: 192.168.0.50
[INFO] HTTP server started on port 8080
[INFO] Scenario loaded: ZACUS_V2 (11 steps)
[INFO] Initial step: RTC_ESP_ETAPE1
[INFO] Audio manager ready
[INFO] LED manager ready (60 LEDs)
[INFO] Ready.
```
### 2.5 Provisioning Credentials
On first boot (or after NVS erase):
```bash
# Erase NVS to force setup mode
pio run -e freenove_esp32s3 -t erase
# Flash and boot — device enters AP mode "Zacus-Setup"
pio run -e freenove_esp32s3 -t upload
# Connect to "Zacus-Setup" WiFi from your phone/laptop
# Open http://192.168.4.1 in browser
# Enter WiFi SSID, password, and API token
# Device reboots in STA mode
```
---
## 3) Content Deployment (LittleFS)
### 3.1 Prepare Filesystem Image
Content files are stored in `hardware/firmware/data/`:
```
data/
story/
runtime3/
DEFAULT.json # Runtime 3 IR (compiled)
scenarios/
DEFAULT.json # Legacy scenario
audio/
ambient_01.mp3
hint_01.mp3
sfx_unlock.mp3
...
config/
device.json # Device-specific config
```
### 3.2 Upload LittleFS Image
```bash
cd hardware/firmware
# Build and upload filesystem image
pio run -e freenove_esp32s3 -t uploadfs --upload-port /dev/tty.usbmodem*
```
### 3.3 Verify Content Upload
```bash
# Via serial
> ls /littlefs/story/runtime3/
DEFAULT.json (4523 bytes)
# Via API (requires auth)
curl -H "Authorization: Bearer <token>" http://192.168.0.50:8080/api/status
# Should show: "scenario": "ZACUS_V2", "steps": 11
```
### 3.4 Update Content Without Reflashing Firmware
For content-only updates (new scenario, audio files):
```bash
# Recompile scenario
python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml
# Copy to firmware data directory
cp artifacts/runtime3/latest/DEFAULT.json hardware/firmware/data/story/runtime3/
# Upload only filesystem (firmware unchanged)
pio run -e freenove_esp32s3 -t uploadfs
```
---
## 4) Frontend Deployment
### 4.1 Build Frontend
```bash
cd frontend-scratch-v2
# Install dependencies
npm install
# Run tests
npm test
# Run lint
npm run lint
# Build for production
VITE_STORY_API_BASE=http://192.168.0.50:8080 npm run build
```
Build output is in `frontend-scratch-v2/dist/`.
### 4.2 Serve Frontend
**Option A: From ESP32 (embedded)**
```bash
# Copy built files to firmware data directory
cp -r frontend-scratch-v2/dist/* hardware/firmware/data/www/
# Upload filesystem
cd hardware/firmware
pio run -e freenove_esp32s3 -t uploadfs
```
The ESP32 serves the frontend at `http://<esp32-ip>:8080/`.
**Option B: From separate server**
```bash
# Serve with any static file server
npx serve frontend-scratch-v2/dist -l 3000
# Or via zacus.sh
./tools/dev/zacus.sh frontend-serve
```
### 4.3 Using the Shell Shortcut
```bash
# Build + lint + test in one command
./tools/dev/zacus.sh frontend-build
```
---
## 5) Network Setup
### 5.1 WiFi Access Point Configuration
| Parameter | Recommended Value |
|-----------|------------------|
| SSID | `EscapeRoom-Net` (hidden optional) |
| Security | WPA2-PSK |
| Channel | 1, 6, or 11 (least congested) |
| Band | 2.4 GHz (ESP32 does not support 5 GHz) |
| DHCP range | 192.168.0.50 - 192.168.0.99 |
| DNS | Not required (local network only) |
### 5.2 IP Address Assignments
| Device | IP | Port | Purpose |
|--------|-----|------|---------|
| WiFi AP / Router | 192.168.0.1 | — | Gateway |
| ESP32 main | 192.168.0.50 | 8080 | Game device |
| ESP32 salle 2 | 192.168.0.51 | 8080 | Secondary device |
| Game master laptop | 192.168.0.10 | 3000 | Dashboard |
| mascarade server | 192.168.0.119 | 4001 | AI backend |
### 5.3 ESP-NOW Pairing
For multi-device setups using ESP-NOW mesh:
```bash
# On primary device (serial console)
> espnow pair
Pairing mode active. Press button on secondary device...
# On secondary device (serial console)
> espnow join
Scanning for primary...
Paired with zacus-main (MAC: AA:BB:CC:DD:EE:FF)
```
### 5.4 Verify Network Connectivity
```bash
# Ping ESP32
ping 192.168.0.50
# Check API health
curl http://192.168.0.50:8080/health
# Expected: {"status":"ok"}
# Check authenticated endpoint
curl -H "Authorization: Bearer <token>" http://192.168.0.50:8080/api/status
# Expected: full status JSON
# Check mascarade (if AI features)
curl http://192.168.0.119:4001/health
```
---
## 6) Post-Deployment Validation
### 6.1 Functional Checks
| # | Check | Command / Action | Expected |
|---|-------|-----------------|----------|
| 1 | Device boots | Power on, watch serial | "Ready." message |
| 2 | WiFi connects | Serial log | IP address assigned |
| 3 | API responds | `curl /health` | `{"status":"ok"}` |
| 4 | Auth works | `curl /api/status` with token | 200 OK |
| 5 | Auth rejects | `curl /api/status` without token | 401 |
| 6 | Scenario loaded | Check `/api/status` response | Correct scenario ID |
| 7 | Audio plays | Trigger audio via API or button | Sound from speaker |
| 8 | LEDs respond | Trigger LED via API or scenario | Correct colors |
| 9 | Frontend loads | Open browser to device IP | UI renders |
| 10 | Full walkthrough | Play through scenario start to finish | All transitions work |
### 6.2 Automated Validation Script
```bash
#!/bin/bash
# tools/deploy/validate.sh
set -euo pipefail
ESP_IP="${1:-192.168.0.50}"
TOKEN="${2:-$(cat .env | grep API_TOKEN | cut -d= -f2)}"
echo "Validating deployment at $ESP_IP..."
# Health check
curl -sf "http://$ESP_IP:8080/health" | jq .status
echo "Health: OK"
# Auth check
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
"http://$ESP_IP:8080/api/status")
[ "$HTTP_CODE" = "200" ] && echo "Auth: OK" || echo "Auth: FAIL ($HTTP_CODE)"
# Scenario check
SCENARIO=$(curl -sf -H "Authorization: Bearer $TOKEN" \
"http://$ESP_IP:8080/api/status" | jq -r .scenario)
echo "Scenario: $SCENARIO"
# Step count
STEPS=$(curl -sf -H "Authorization: Bearer $TOKEN" \
"http://$ESP_IP:8080/api/status" | jq .steps)
echo "Steps: $STEPS"
echo "Validation complete."
```
---
## 7) Rollback Procedure
### 7.1 Firmware Rollback
```bash
# Keep previous firmware binary
cp .pio/build/freenove_esp32s3/firmware.bin backups/firmware_$(date +%Y%m%d).bin
# To rollback:
pio run -e freenove_esp32s3 -t upload --upload-port /dev/tty.usbmodem* \
--firmware backups/firmware_20260320.bin
```
### 7.2 Content Rollback
```bash
# Content is versioned in git
git log --oneline hardware/firmware/data/story/
# Restore previous version
git checkout HEAD~1 -- hardware/firmware/data/story/runtime3/DEFAULT.json
# Re-upload filesystem
cd hardware/firmware
pio run -e freenove_esp32s3 -t uploadfs
```
### 7.3 Frontend Rollback
```bash
# Previous builds in git
git log --oneline frontend-scratch-v2/
# Restore and rebuild
git checkout HEAD~1 -- frontend-scratch-v2/
cd frontend-scratch-v2 && npm run build
```
### 7.4 Emergency: Factory Reset
```bash
# Full erase (firmware + NVS + filesystem)
pio run -e freenove_esp32s3 -t erase
# Reflash everything from scratch
pio run -e freenove_esp32s3 -t upload
pio run -e freenove_esp32s3 -t uploadfs
# Re-provision credentials via AP mode
```
---
## 8) Troubleshooting Quick Reference
| Symptom | Likely Cause | Fix |
|---------|-------------|-----|
| No serial output | Wrong USB port or baud rate | Try `115200` baud, check cable (data-capable) |
| Boot loop | Stack overflow or panic | Increase stack in `platformio.ini` (16 -> 24 KB) |
| WiFi won't connect | Wrong credentials or channel | Erase NVS, re-provision. Check 2.4 GHz only |
| WiFi keeps disconnecting | Signal too weak or interference | Move AP closer, change channel |
| API returns 401 | Token mismatch | Re-provision token via AP setup portal |
| API returns 429 | Rate limited | Wait 1 s, reduce request frequency |
| No audio | I2S pin mismatch or volume 0 | Check `platformio.ini` pin defines, set volume > 0 |
| Audio distorted | DMA buffer underrun | Reduce concurrent tasks, increase DMA buffer count |
| LEDs wrong color | GRB/RGB order mismatch | Check LED type in code (WS2812B = GRB) |
| LEDs flicker | Insufficient power supply | Use dedicated 5V supply, add 100uF cap |
| LVGL crash | Pool too small | Increase to 96 KB in `platformio.ini` |
| Scenario stuck | Missing transition for current state | Check `DEFAULT.json` transitions, use serial `> status` |
| ESP-NOW not pairing | Different WiFi channels | Both devices must be on same channel |
| Camera timeout | I2C conflict or power issue | Check camera ribbon cable, power cycle |
| OOM / heap exhaustion | Memory leak or too many features | Monitor with `> heap`, disable unused features |
| Frontend won't connect | Wrong `VITE_STORY_API_BASE` | Rebuild with correct ESP32 IP |
| mascarade unreachable | Network or Docker issue | Check `ping`, `docker ps`, firewall rules |
### Serial Debug Commands
```
> status # Device state, heap, uptime
> heap # Detailed memory breakdown
> wifi # WiFi RSSI, IP, channel
> scenario # Current step, available transitions
> transition <event> # Manually trigger a transition
> audio list # List available audio files
> audio play <file> # Play an audio file
> led test # Cycle through LED test patterns
> reboot # Software restart
> factory-reset # Erase NVS + reboot into AP mode
```
### Useful Monitoring Commands
```bash
# Watch serial output continuously
pio device monitor -e freenove_esp32s3 -b 115200
# Stream API status every 5 seconds
watch -n 5 'curl -s -H "Authorization: Bearer <token>" http://192.168.0.50:8080/api/status | jq .'
# Check WiFi signal from ESP32 perspective
curl -s -H "Authorization: Bearer <token>" http://192.168.0.50:8080/api/status | jq .wifi_rssi
```
-87
View File
@@ -1,87 +0,0 @@
# Structure & Configuration Fixes (2026-03-01)
## Summary
Fixed the double `hardware/firmware` directory duplication and locked `cockpit.sh` to use ONLY PlatformIO CLI.
## Changes Made
### 1. Directory Structure Correction
**Problem:** Double nesting `hardware/firmware/hardware/firmware/` contained actual sources
**Solution:**
- Moved `esp32_audio/`, `ui_freenove_allinone/`, `ui/` to correct location
- Removed empty `hardware/firmware/hardware/` directory
- New clean structure: `hardware/firmware/{esp32_audio/, ui_freenove_allinone/, ui/, lib/, ...}`
### 2. platformio.ini Fixes
**Problem:** Paths referenced `hardware/firmware` double, wrong `libs` path
**Changes:**
```ini
# Before:
src_dir = hardware/firmware
build_flags = -Ihardware/firmware/esp32_audio/src -I$PROJECT_DIR/hardware/libs/story
# After:
src_dir = .
build_flags = -I./esp32_audio/src -I$PROJECT_DIR/lib/story
```
**All corrections:**
- `src_dir`: `hardware/firmware``.` (current directory only)
- Include paths: Removed all `hardware/firmware` double-references
- Library paths: `libs/``lib/` (correct singular form)
- Relative includes: `./lib/story` instead of complex relative paths
### 3. cockpit.sh Locked to PIO-Only
**Problem:** Complex script with hardware detection, artifact management, build orchestration
**Solution:** Rewritten as pure PlatformIO CLI wrapper
**Removed:**
- ❌ Hardware auto-detection
- ❌ build_all.sh integration
- ❌ Artifact collection/logging
- ❌ Complex FX/graphics verification
- ❌ 600+ lines of legacy code
**Added:**
- ✅ Direct pio run / pio run -t upload / pio device monitor
- ✅ Environment listing (envs) and port detection (ports)
- ✅ Locked contract: "ONLY uses PIO (PlatformIO CLI)"
- ✅ Clean 113-line script with help, examples, clear contract
**New usage:**
```bash
./tools/dev/cockpit.sh build [env] # pio run -e $env
./tools/dev/cockpit.sh flash [env] # pio run -e $env -t upload
./tools/dev/cockpit.sh monitor [env] # pio device monitor -e $env
./tools/dev/cockpit.sh go [env] # Build + Flash + Monitor combo
./tools/dev/cockpit.sh envs # List available environments
./tools/dev/cockpit.sh ports # List connected serial ports
./tools/dev/cockpit.sh help # Show help with contract
```
## Files Modified
1. `platformio.ini` - Fixed all path references
2. `tools/dev/cockpit.sh` - Rewrote to locked PIO-only contract
## Verification
```bash
# Verify directory structure
ls -1d hardware/firmware/{esp32_audio,ui_freenove_allinone,ui,lib}
# Output: All 4 dirs present ✓
# Verify cockpit.sh works
./tools/dev/cockpit.sh envs
# Output: Lists 9+ environments ✓
# Verify no more hardware/firmware double-refs
grep -c 'hardware/firmware' platformio.ini
# Output: 0 ✓
```
## Notes
- Build may show missing headers (e.g., `boot_protocol_runtime.h`) - this is a separate issue in source files, not related to the path corrections
- cockpit.sh now has a strict locked contract: zero custom logic, zero shell complexity
- All future cockpit.sh changes must use ONLY `pio` commands, no custom detection/scripts
## Rollback (if needed)
Original files backed up in git; use `git checkout hardware/firmware/platformio.ini tools/dev/cockpit.sh` to revert.
+80 -63
View File
@@ -1,91 +1,96 @@
# Générer un scénario STORY V2 (firmware ESP32) # Générer un scénario STORY V2
Ce guide explique **comment créer/modifier un scénario** pour le firmware ESP32 sans toucher au moteur C++. Ce guide explique **comment créer, compiler et tester un scénario** avec le Runtime 3 sans toucher au moteur C++.
## Prérequis ## Prérequis
Depuis ce dossier: - Python 3.x avec PyYAML installé (`pip install pyyaml`)
- Le dépôt cloné à la racine du projet
```bash ## 1) Source canonique du scénario
cd hardware/firmware/esp32_audio
Le fichier de référence est :
```
game/scenarios/zacus_v2.yaml
``` ```
## 1) Créer (ou dupliquer) un scénario YAML C'est la **single source of truth**. Toute modification de scénario doit passer par ce fichier YAML.
Point de départ recommandé: ## 2) Édition visuelle (studio Blockly)
- template: `docs/protocols/story_specs/templates/scenario.template.yaml` Pour éditer le scénario graphiquement, utiliser le studio React + Blockly :
- exemple existant: `docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml`
- schéma de référence: `docs/protocols/story_specs/schema/story_spec_v1.yaml`
Crée un nouveau fichier dans `docs/protocols/story_specs/scenarios/`, par exemple: ```bash
cd frontend-scratch-v2
npm install
npm run dev
```
`docs/protocols/story_specs/scenarios/mon_scenario.yaml` Le studio permet de manipuler les étapes, transitions et événements visuellement, puis d'exporter vers le format YAML V2.
Exemple prêt à l'emploi dans le repo:
- `docs/protocols/story_specs/scenarios/example_unlock_express.yaml`
- `docs/protocols/story_specs/scenarios/example_unlock_express_done.yaml`
## 2) Définir la structure minimale
Dans le YAML, renseigne au minimum:
- `id` (identifiant unique)
- `version` (utiliser la version supportée par le générateur; dans ce repo les exemples sont en `2`)
- `initial_step`
- `app_bindings`
- `steps`
- `transitions`
Règle pratique:
- chaque `target_step_id` doit pointer vers une étape existante,
- les IDs doivent être uniques,
- les transitions doivent permettre datteindre une fin logique (`DONE` ou équivalent).
## 3) Valider le scénario ## 3) Valider le scénario
```bash ```bash
make story-validate make scenario-validate
``` ```
Cette commande vérifie: Cette commande vérifie :
- la conformité de structure, - la conformité de structure au schéma V2,
- les IDs / références, - les IDs / références croisées,
- la cohérence des transitions, - la cohérence des transitions,
- les bindings des mini-apps. - les bindings des mini-apps.
Corrige les erreurs jusquà obtenir une validation propre. Corriger les erreurs jusqu'à obtenir une validation propre.
## 4) Générer le code C++ ## 4) Compiler avec le Runtime 3
```bash ```bash
make story-gen make runtime3-compile
``` ```
Le générateur produit les fichiers utilisés par le runtime V2: Cela exécute `tools/scenario/compile_runtime3.py` sur le scénario par défaut. Pour spécifier un fichier :
- `src/story/generated/scenarios_gen.h`
- `src/story/generated/scenarios_gen.cpp`
- `src/story/generated/apps_gen.h`
- `src/story/generated/apps_gen.cpp`
## 5) Compiler et flasher
```bash ```bash
make runtime3-compile SCENARIO=game/scenarios/zacus_v2.yaml
```
## 5) Simuler le scénario
```bash
make runtime3-simulate
```
Cela exécute `tools/scenario/simulate_runtime3.py` et déroule le scénario hors firmware pour vérifier les transitions et les états finaux.
## 6) Vérifier les pivots et générer le bundle firmware
```bash
make runtime3-verify
make runtime3-firmware-bundle
```
- `runtime3-verify` valide les points pivots du graphe de scénario.
- `runtime3-firmware-bundle` produit le bundle prêt à flasher sur l'ESP32.
## 7) Lancer les tests unitaires Runtime 3
```bash
make runtime3-test
```
## 8) Compiler et flasher le firmware
```bash
cd ESP32_ZACUS
pio run -e esp32dev pio run -e esp32dev
```
Puis upload selon ton port série:
```bash
pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0 pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0
``` ```
## 6) Tester côté série ## 9) Tester côté série
Dans le moniteur série, commandes utiles: Dans le moniteur série, commandes utiles :
- `STORY_V2_ENABLE ON` - `STORY_V2_ENABLE ON`
- `STORY_V2_LIST` - `STORY_V2_LIST`
@@ -97,15 +102,27 @@ Dans le moniteur série, commandes utiles:
## Workflow court (copier-coller) ## Workflow court (copier-coller)
```bash ```bash
cd hardware/firmware/esp32_audio make scenario-validate
make story-validate make runtime3-compile
make story-gen make runtime3-simulate
pio run -e esp32dev make runtime3-verify
``` ```
## Toutes les cibles Makefile disponibles
| Cible | Description |
|---|---|
| `scenario-validate` | Validation YAML du scénario |
| `runtime3-compile` | Compilation Runtime 3 |
| `runtime3-simulate` | Simulation hors firmware |
| `runtime3-verify` | Vérification des pivots |
| `runtime3-test` | Tests unitaires Runtime 3 |
| `runtime3-firmware-bundle` | Export bundle firmware ESP32 |
## Conseils pour éviter les blocages ## Conseils pour éviter les blocages
- Commence simple: 3 à 4 étapes max au début. - Commencer simple : 3 à 4 étapes max au début.
- Najoute quun seul nouveau type dévénement à la fois. - N'ajouter qu'un seul nouveau type d'événement à la fois.
- Teste chaque transition clé au moniteur série (`STORY_V2_EVENT ...`). - Toujours valider (`make scenario-validate`) avant de compiler.
- Garde un scénario « de secours » valide dans `docs/protocols/story_specs/scenarios/`. - Tester chaque transition clé au moniteur série (`STORY_V2_EVENT ...`).
- Utiliser `make runtime3-simulate` pour déboguer sans flasher.
+77 -61
View File
@@ -1,82 +1,98 @@
# Quickstart # Quickstart
## En un coup d’œil ## Vue canonique
- Source de vérité : `game/scenarios/zacus_v2.yaml` (dérive tout le reste : kit MJ, audio, printables). - Source de vérité: `game/scenarios/zacus_v2.yaml`
- Public : 6 à 14 enfants, 105 minutes (45 + 60). - Studio auteur: `frontend-scratch-v2/`
- Matériel : `kit-maitre-du-jeu/`, `printables/`, `game/scenarios/zacus_v2.yaml`, `audio/manifests/zacus_v2_audio.yaml`, `hardware/firmware/` (ESP32 + écran tactile). - Runtime portable: Zacus Runtime 3
- Licences : contenus créatifs CC BY-NC 4.0 (`LICENSES/CC-BY-NC-4.0.txt`), code/script MIT (`LICENSES/MIT.txt`). - Cible terrain principale: `hardware/firmware` sur `freenove_esp32s3`
- Shell d'orchestration: `tools/dev/zacus.sh`
## Installer
1. Copie les imprimables depuis `printables/export/pdf/` ou génère-les via `printables/manifests/zacus_v2_printables.yaml` + `printables/src/prompts/`.
2. Prépare un lecteur audio avec `audio/manifests/zacus_v2_audio.yaml` et les fichiers de `game/prompts/audio/`.
3. Positionne les stations selon `kit-maitre-du-jeu/plan-stations-et-mise-en-place.md`, prépare les rôles (`distribution-des-roles.md`) et laccueil rapide (`script-minute-par-minute.md`).
4. Flash et configure lESP32 en suivant `hardware/firmware/README.md` pour que linterface réagisse aux stations imprimées.
## Préparer l’électronique
1. Installe PlatformIO (`pip install -U platformio`) et branche lESP32 décrit dans `hardware/firmware/README.md`, écran + alim.
2. Compile/flash avec un profil supporté (`pio run -e esp32dev -t upload`), puis charge les assets `pio run -e esp32dev -t uploadfs` si nécessaire.
3. Vérifie que l’écran affiche le scénario (`python3 tools/scenario/validate_scenario.py ...`), la connectique audio/led est prête, et que lESP32 reste branché pendant toute la partie.
## Déroulé express
1. Accueil + immersion (0-10 min) : boucle audio d`intro.md`, attribution des rôles, mise au courant sur les règles anti-chaos.
2. Support denquête (10-80 min) : station par station, fiche denquête, audio `incident.md`, indices printables.
3. Synthèse & final (80-105 min) : audio `accusation.md`, accusation finale, audio `solution.md`, lecture `solution-complete.md`.
## Outils de validation
- Scénario : `python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml`
- Audio : `python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml`
- Printables : `python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml`
- Export Markdown : `python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml`
## Frontend V2
- Frontend canon : `frontend-scratch-v2/`.
- Démarrage :
- `cd frontend-scratch-v2`
- `npm install`
- `VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev`
## Outils test firmware
Entrée rapide:
## Valider le contenu
```bash ```bash
python3 tools/test/zacus_menu.py bash tools/setup/install_validators.sh
bash tools/test/run_content_checks.sh --install-missing
``` ```
Codex CLI intégré : `./tools/dev/zacus.sh codex --prompt tools/dev/codex_prompts/zacus_overhaul_one_shot.md` Checks inclus:
Préflight USB ZeroClaw : `./tools/dev/zacus.sh zeroclaw-preflight --require-port` - validation scénario
- compilation Runtime 3
Checks contenu (sans hardware): - simulation Runtime 3
- validation runtime bundle
- validation audio
- validation printables
- export Markdown
## Compiler le runtime portable
```bash ```bash
bash tools/test/run_content_checks.sh python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml
python3 tools/scenario/simulate_runtime3.py game/scenarios/zacus_v2.yaml
python3 tools/scenario/verify_runtime3_pivots.py game/scenarios/zacus_v2.yaml
python3 -m unittest discover -s tests/runtime3 -p 'test_*.py'
python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml
``` ```
Suites série disponibles: Ou via le shell canonique:
```bash ```bash
python3 tools/test/run_serial_suite.py --list-suites ./tools/dev/zacus.sh runtime3-compile
./tools/dev/zacus.sh runtime3-simulate
./tools/dev/zacus.sh runtime3-verify
./tools/dev/zacus.sh runtime3-test
./tools/dev/zacus.sh frontend-test
``` ```
Mode laptop/CI (sans carte branchée): Les artefacts sont écrits sous `artifacts/runtime3/<timestamp>/`.
Le bundle firmware canonique est écrit dans `hardware/firmware/data/story/runtime3/DEFAULT.json`.
## Démarrer le studio React + Blockly
```bash ```bash
python3 tools/test/run_serial_suite.py --suite smoke_plus --allow-no-hardware cd frontend-scratch-v2
python3 tools/test/zacus_menu.py --action smoke --allow-no-hardware npm install
npm test
npm run lint
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
``` ```
Pour résoudre les ports sans matériel, la commande `./tools/dev/zacus.sh ports` écrit le JSON contractuel dans `artifacts/ports/<timestamp>/ports_resolve.json` et met à jour `artifacts/ports/latest_ports_resolve.json`. Pour mocker les CP2102 utilisez `ZACUS_MOCK_PORTS=1 ZACUS_PORTS_FIXTURE=tools/test/fixtures/ports_list_macos.txt ./tools/dev/zacus.sh ports`. Pour un build local:
```bash
./tools/dev/zacus.sh frontend-build
```
Codex CLI intégré : `./tools/dev/zacus.sh codex --prompt tools/dev/codex_prompts/zacus_overhaul_one_shot.md`. Le script note aussi l’état des ports résolus et place les logs dans `artifacts/codex/<timestamp>/`. ## Générer la documentation
```bash
python3 -m pip install --user --break-system-packages -r tools/requirements/docs.txt
python3 -m mkdocs build --strict
```
Guide orchestration ZeroClaw: `docs/zeroclaw_orchestration.md` Ou:
```bash
./tools/dev/zacus.sh docs-build
```
Dépendances optionnelles: ## Valider le firmware
- `pip install pyyaml` pour `run_content_checks.sh` ```bash
- `pip install pyserial` pour suites USB, UI Link sim et console série cd hardware/firmware
pio run -e freenove_esp32s3
pio run -e esp8266_oled
```
## Pour aller plus loin Chemin mono-carte recommandé:
- Crée une variante : duplique le YAML, modifie `canon` et `solution` et revalide avec le script. ```bash
- Ajoute des cartes imprimables en tinspirant des prompts dans `printables/src/prompts/`. cd hardware/firmware
- Mets à jour ce quickstart via un PR si le montage change (p. ex. nouvelle station ou nouvelle plage horaire). ZACUS_ENV="freenove_esp32s3" ./tools/dev/run_matrix_and_smoke.sh
```
## Utiliser le shell Zacus
```bash
./tools/dev/zacus.sh content-checks
./tools/dev/zacus.sh ports
./tools/dev/zacus.sh artifacts-summary
./tools/dev/zacus.sh menu
```
Le menu utilise `gum` si disponible et repasse en mode texte sinon.
## Où regarder ensuite
- `docs/architecture/index.md`
- `plans/master-plan.md`
- `todos/master.md`
- `hardware/firmware/docs/AGENT_TODO.md`
-106
View File
@@ -1,106 +0,0 @@
# RC AutoFix (Scheduled) Workflow
This document describes the GitHub Actions workflow at .github/workflows/rc-autofix-cicd.yml.
## Trigger Manually (GitHub UI)
1. Go to Actions tab
2. Select "RC AutoFix (Scheduled)"
3. Click "Run workflow"
## Trigger via CLI
```bash
gh workflow run rc-autofix-cicd.yml
```
## Automatic Scheduled Run
- Runs daily at 2 AM UTC (adjust cron expression as needed)
- Can be modified in workflow file under schedule.cron
## Environment Variables Explained
| Variable | Value | Purpose |
|----------|-------|---------|
| ZACUS_GIT_AUTOCOMMIT | 1 | Enable automatic git commits after Codex fixes |
| ZACUS_GIT_ALLOW_WRITE | 1 | Allow git write operations (required for commits) |
| ZACUS_GIT_NO_CONFIRM | 1 | Skip confirmation prompts (required for automation) |
| ZACUS_REQUIRE_HW | 0 | Do not require physical hardware (simulation mode) |
| ZACUS_SKIP_UPLOAD | 1 | Skip ESP32/ESP8266 upload (stability + speed) |
## Workflow Steps
1. Checkout code from current branch
2. Setup Python and PlatformIO
3. Bootstrap Zacus environment
4. Configure Git user for commits
5. Run RC AutoFix with auto-commit enabled
6. Push changes if any commits were made
7. Upload artifacts for review (30-day retention)
8. Notify on failure (optional: configure GitHub issue comment)
## Tracking Results
### Via GitHub Actions UI
- Check "Run workflow" and the latest run
- View step logs for detailed output
- Download artifacts (rc_autofix.log, codex_last_message.md, etc.)
### Via Git History
```bash
git log --oneline | grep "Auto-fix:" # Show all auto-fix commits
git show HEAD # Review latest commit
```
## Failure Modes
| Failure | Action |
|---------|--------|
| RC pre-condition fails | Workflow continues (will fail unless fix applied) |
| Codex execution fails | Check logs in artifact/codex_last_message.md |
| Git commit fails | Check git config and repository permissions |
| Push fails | Check branch protection rules and credentials |
## Customization
### Change Schedule
Edit the cron expression:
```yaml
schedule:
- cron: '0 2 * * 1-5' # Mon-Fri at 2 AM UTC
- cron: '0 6 * * *' # Daily at 6 AM UTC
```
### Disable Auto-Commit
Set ZACUS_GIT_AUTOCOMMIT: '0' (requires manual commit review).
### Add Hardware Testing
Set ZACUS_REQUIRE_HW: '1' (requires physical ESP32/ESP8266 and a CI runner with USB).
### Add Slack Notification
```yaml
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1.24.0
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "RC AutoFix failed - check artifacts"
}
```
## References
- Zacus CLI: hardware/firmware/tools/dev/zacus.sh
- RC Live Codex Status: hardware/firmware/docs/RC_LIVE_CODEX_STATUS.md
- Codex Prompt Maintenance: hardware/firmware/docs/CODEX_PROMPT_MAINTENANCE.md
- Agent Briefing: .github/agents/AGENT_BRIEFINGS.md
-11
View File
@@ -1,11 +0,0 @@
<!-- REPO_STATE:v1 -->
Repo: le-mystere-professeur-zacus
Branch: codex/repo-state-zacus
HEAD: 3fec631c43c66a6ae12c12d2f652c8800d22b28f
HeadDate: 2026-02-21T02:59:01+01:00
HeadSubject: feat(freenove): stabilize fallback AP outside local wifi (#103)
RepoURL: https://github.com/electron-rare/le-mystere-professeur-zacus.git
ProjectKind: hardware_firmware_hybrid
PivotChanges: [{"path": "hardware/firmware/data/story/apps/APP_WIFI.json", "tags": ["firmware_build_test", "hardware_validation"]}, {"path": "hardware/firmware/docs/AGENT_TODO.md", "tags": ["firmware_build_test", "hardware_validation"]}, {"path": "hardware/firmware/hardware/firmware/ui_freenove_allinone/include/network_manager.h", "tags": ["firmware_build_test", "hardware_validation"]}, {"path": "hardware/firmware/hardware/firmware/ui_freenove_allinone/src/main.cpp", "tags": ["firmware_build_test", "hardware_validation"]}, {"path": "hardware/firmware/hardware/firmware/ui_freenove_allinone/src/network_manager.cpp", "tags": ["firmware_build_test", "hardware_validation"]}]
ImpactGates: firmware_build_test, hardware_validation
GeneratedAtUTC: 2026-02-21T02:23:01Z
+319
View File
@@ -0,0 +1,319 @@
# Security Specification
## Status
- State: draft
- Date: 2026-03-21
- Supersedes: previous 4-line stub
- Related: `specs/AI_INTEGRATION_SPEC.md`, `specs/MCP_HARDWARE_SERVER_SPEC.md`
## 1) Threat Model
### 1.1 Deployment Context
The Zacus escape room system operates on a **local WiFi network** in a controlled physical space. It is not exposed to the public internet. The primary attack surface is:
| Vector | Risk | Likelihood |
|--------|------|-----------|
| Local network access (same WiFi) | Players or visitors on the game WiFi | HIGH |
| Physical access to ESP32 device | Players can touch/see the hardware | HIGH |
| Firmware extraction (USB/JTAG) | Sophisticated attacker with tools | LOW |
| Server-side (mascarade VM) | Only reachable via LAN/Tailscale | LOW |
| Supply chain (dependencies) | Compromised PlatformIO/npm package | VERY LOW |
### 1.2 Assets to Protect
| Asset | Sensitivity | Impact if Compromised |
|-------|------------|----------------------|
| WiFi credentials | HIGH | Network access, lateral movement |
| API bearer tokens | HIGH | Full device control |
| Game scenario content | MEDIUM | Spoilers, cheating |
| Player interaction data | LOW | Privacy (no PII collected) |
| Firmware binary | LOW | IP, reverse engineering |
| mascarade API key | HIGH | Full orchestration control |
### 1.3 Threat Actors
1. **Curious player**: Tries to cheat by accessing the API from their phone
2. **Tech-savvy visitor**: Scans the network, finds ESP32 endpoints
3. **Malicious local user**: Attempts injection, DoS, or firmware dump
4. **Remote attacker**: Not applicable (no internet exposure)
## 2) API Authentication
### 2.1 Bearer Token Scheme
All ESP32 HTTP API endpoints require a Bearer token:
```
GET /api/status
Authorization: Bearer <token>
Response 200: { "step": "STEP_U_SON_PROTO", ... }
Response 401: { "error": "unauthorized" }
```
### 2.2 Token Lifecycle
| Phase | Mechanism |
|-------|-----------|
| Generation | Random 48-char hex string (cryptographically secure) |
| Storage (ESP32) | NVS encrypted partition |
| Storage (server) | `.env` file, not committed to git |
| Provisioning | QR code scanned at setup (see 3.2) |
| Rotation | Manual, at each deployment |
| Revocation | Reflash NVS or change `.env` |
### 2.3 Endpoint Protection Matrix
| Endpoint | Auth Required | Rate Limit | Notes |
|----------|:------------:|:----------:|-------|
| `GET /api/status` | Yes | 10/s | Device health |
| `GET /api/scenario` | Yes | 5/s | Current state |
| `POST /api/scenario/transition` | Yes | 2/s | State change |
| `POST /api/audio` | Yes | 5/s | Audio control |
| `POST /api/led` | Yes | 10/s | LED control |
| `GET /api/camera` | Yes | 2/s | Snapshot |
| `POST /api/puzzle` | Yes | 2/s | Puzzle state |
| `GET /` | No | 20/s | Web UI (static) |
| `GET /health` | No | 20/s | Health check |
| `WS /ws` | Yes (on connect) | — | Event stream |
### 2.4 Implementation (Firmware)
```cpp
// Auth middleware for AsyncWebServer
bool checkAuth(AsyncWebServerRequest *request) {
if (!request->hasHeader("Authorization")) {
request->send(401, "application/json", "{\"error\":\"unauthorized\"}");
return false;
}
String authHeader = request->header("Authorization");
if (!authHeader.startsWith("Bearer ")) {
request->send(401, "application/json", "{\"error\":\"unauthorized\"}");
return false;
}
String token = authHeader.substring(7);
String storedToken = nvs_get_string("auth", "api_token");
if (token != storedToken) {
request->send(401, "application/json", "{\"error\":\"unauthorized\"}");
return false;
}
return true;
}
```
## 3) WiFi Credential Management
### 3.1 Current State (CRITICAL — FW-01)
WiFi credentials are currently **hardcoded** in `storage_manager.cpp:73`. This must be remediated immediately.
### 3.2 Target: NVS + QR Provisioning
**Storage**: ESP32 NVS (Non-Volatile Storage) encrypted partition.
**Provisioning flow**:
1. First boot: ESP32 starts in AP mode (`Zacus-Setup`)
2. Game master connects and opens captive portal
3. Game master scans QR code containing WiFi config:
```
WIFI:T:WPA;S:EscapeRoom-Net;P:s3cur3p4ss;;
```
4. ESP32 stores credentials in NVS, restarts in STA mode
5. On connection failure: fallback to AP mode after 30 s
**NVS keys**:
| Key | Namespace | Encrypted |
|-----|-----------|:---------:|
| `wifi_ssid` | `network` | Yes |
| `wifi_pass` | `network` | Yes |
| `api_token` | `auth` | Yes |
| `device_id` | `device` | No |
| `mcp_token` | `auth` | Yes |
### 3.3 NVS Encryption
Enable NVS encryption with a flash encryption key:
```ini
# platformio.ini
board_build.partitions = partitions_encrypted.csv
board_build.flash_mode = dio
board_build.esp-idf.sdkconfig =
CONFIG_NVS_ENCRYPTION=y
CONFIG_SECURE_FLASH_ENC_ENABLED=y
```
## 4) Input Validation
### 4.1 Requirements
All API inputs must be validated before processing:
| Input | Validation |
|-------|-----------|
| JSON body | Max size 4 KB, max depth 5 levels |
| `step_id` | Alphanumeric + underscore, max 64 chars, must exist in scenario |
| `event_name` | Alphanumeric + underscore, max 64 chars |
| `audio_path` | Must start with `/audio/`, no `..`, max 128 chars |
| `color` | Regex `^#[0-9a-fA-F]{6}$` or named color enum |
| `volume` | Integer 0-100 |
| `brightness` | Integer 0-255 |
| `device_id` | Alphanumeric + hyphen, max 32 chars |
### 4.2 JSON Parsing Safety
```cpp
// Safe JSON parsing with limits
StaticJsonDocument<4096> doc; // 4 KB max
DeserializationError err = deserializeJson(doc, body, DeserializationOption::NestingLimit(5));
if (err) {
request->send(400, "application/json", "{\"error\":\"invalid_json\"}");
return;
}
```
### 4.3 String Safety
- All string operations use length-bounded functions (`strncpy`, `snprintf`)
- No `sprintf` or unbounded `strcpy` in firmware code
- LVGL text buffers use fixed-size arrays with null termination
## 5) Rate Limiting
### 5.1 Token Bucket Algorithm
Each endpoint has a token bucket rate limiter:
```cpp
struct RateLimiter {
uint32_t tokens;
uint32_t max_tokens;
uint32_t refill_rate_ms; // ms per token refill
uint32_t last_refill;
};
```
### 5.2 Limits
| Category | Rate | Burst |
|----------|------|-------|
| Read endpoints (GET) | 10/s | 20 |
| Write endpoints (POST) | 5/s | 10 |
| Camera capture | 2/s | 3 |
| State transitions | 2/s | 5 |
| WebSocket messages | 20/s | 50 |
| Static files | 20/s | 50 |
### 5.3 Response on Limit
```
HTTP 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{"error": "rate_limited", "retry_after_ms": 1000}
```
## 6) CORS Policy
### 6.1 Configuration
```cpp
// CORS headers for AsyncWebServer
server.on("/*", HTTP_OPTIONS, [](AsyncWebServerRequest *request) {
AsyncWebServerResponse *response = request->beginResponse(204);
response->addHeader("Access-Control-Allow-Origin", allowedOrigin);
response->addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response->addHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
response->addHeader("Access-Control-Max-Age", "3600");
request->send(response);
});
```
### 6.2 Allowed Origins
| Environment | Allowed Origin |
|-------------|---------------|
| Development | `http://localhost:5173` (Vite dev server) |
| Production | `http://<esp32-ip>:8080` (self-hosted UI) |
| Game master | `http://<gm-dashboard-ip>:3000` |
Wildcard `*` is **never** used.
## 7) Future Security Enhancements
### 7.1 Secure Boot (Phase 2)
Enable ESP32-S3 Secure Boot v2:
- RSA-3072 signature verification on boot
- Prevents unsigned firmware from running
- Key stored in eFuse (one-time programmable)
```ini
# platformio.ini (future)
board_build.esp-idf.sdkconfig =
CONFIG_SECURE_BOOT=y
CONFIG_SECURE_BOOT_V2_ENABLED=y
```
### 7.2 Flash Encryption (Phase 2)
Enable flash encryption to protect firmware and NVS:
- AES-256-XTS encryption
- Prevents firmware extraction via SPI flash dump
- Development mode allows re-flashing; release mode is permanent
### 7.3 TLS / HTTPS (Phase 3)
Upgrade HTTP API to HTTPS:
- Self-signed certificate stored in NVS
- Certificate pinning in frontend and MCP client
- Adds ~50 KB RAM overhead on ESP32
### 7.4 OTA Security (Phase 3)
Signed OTA updates:
- Firmware images signed with project key
- ESP32 verifies signature before applying update
- Rollback on failed verification
## 8) Incident Response
### 8.1 Severity Levels
| Level | Description | Example |
|-------|------------|---------|
| SEV-1 | Game completely compromised | Token leaked, full device control by attacker |
| SEV-2 | Security bypass discovered | Auth bypass, injection working |
| SEV-3 | Vulnerability found, not exploited | Missing validation on one endpoint |
| SEV-4 | Hardening improvement | Missing rate limit on low-risk endpoint |
### 8.2 Response Procedure
1. **Detect**: Monitor serial logs, API access patterns, unexpected state changes
2. **Contain**: Power off affected devices if SEV-1/SEV-2
3. **Assess**: Identify scope (which devices, which data)
4. **Remediate**:
- Rotate all tokens immediately
- Reflash firmware with fix
- Update NVS credentials
5. **Review**: Post-incident analysis, update threat model
### 8.3 Contact
Security issues: open a private issue on the GitHub repository or contact the project maintainer directly. Do not disclose vulnerabilities publicly before a fix is available.
## 9) Security Checklist (Pre-Deployment)
- [ ] WiFi credentials stored in NVS, not in source code
- [ ] API bearer token generated and provisioned
- [ ] All API endpoints require authentication
- [ ] Input validation on all POST endpoints
- [ ] Rate limiting enabled
- [ ] CORS configured with specific origins
- [ ] No credentials in git history
- [ ] Serial debug output disabled in production build
- [ ] Firmware compiled with optimization (-Os)
- [ ] NVS encryption enabled
- [ ] Default passwords changed
- [ ] `.env` file excluded from git (in `.gitignore`)
+34 -189
View File
@@ -1,196 +1,41 @@
# Repository Structure # Repository Structure
## Overview ## Canon de refonte
This repository contains all materials for "Le Mystère du Professeur Zacus" - a complete investigation kit for birthday parties (6-14 children, 105 minutes), with a laboratory/scientific campus theme. ```mermaid
flowchart TD
## Root Structure YAML["game/scenarios/*.yaml"] --> Runtime3["tools/scenario Runtime 3"]
YAML --> Exports["audio / printables / kit MJ"]
``` Runtime3 --> Studio["frontend-scratch-v2"]
le-mystere-professeur-zacus/ Runtime3 --> Firmware["hardware/firmware"]
├── .github/ # GitHub configuration (workflows, templates) Plans["memory + plans + todos"] --> Studio
├── audio/ # Audio manifests and prompts Plans --> Firmware
├── docs/ # Documentation Docs["docs/architecture + specs"] --> Plans
├── examples/ # Example usages and demonstrations
├── game/ # Core game scenarios and rules
├── hardware/ # Firmware for microcontrollers
├── kit-maitre-du-jeu/ # Game master kit (scripts, solutions, setup)
├── ops/ # Operations scripts and utilities
├── printables/ # Printable materials (badges, cards, clues)
├── scenario-ai-coherence/# AI-generated scenario content (human-validated)
├── tools/ # Validation and export scripts
└── [root config files] # LICENSE, README, CONTRIBUTING, etc.
``` ```
## Detailed Directory Structure ## Répertoires principaux
- `.github/`: CI et politiques de validation.
- `audio/`: manifestes et ressources audio.
- `docs/`: quickstart, architecture, benchmark OSS, runbooks.
- `frontend-scratch-v2/`: studio auteur React + Blockly et prévisualisation Runtime 3.
- `game/`: scénarios YAML canoniques.
- `hardware/`: firmware, scripts de test terrain et documentation matériel.
- `kit-maitre-du-jeu/`: déroulé MJ, organisation des stations, scripts de partie.
- `memory/`: mémoire projet transversale.
- `plans/`: plans directeurs et plans d'agents.
- `printables/`: manifestes et sources d'indices imprimables.
- `specs/`: spécifications canoniques de la refonte.
- `todos/`: backlog opérationnel de la refonte.
- `tools/`: validateurs, compilateur/simulateur Runtime 3, shells TUI/CLI.
### `.github/` ## Règles de structure
GitHub-specific configuration files: - `game/scenarios/*.yaml` reste la source de vérité narrative.
- **`ISSUE_TEMPLATE/`**: Issue templates for bugs and features - `hardware/firmware/esp32/` est en lecture seule.
- **`PULL_REQUEST_TEMPLATE.md`**: Pull request template - `frontend-scratch-v2/` est le front canonique; les chemins legacy doivent sortir du flux principal.
- **`workflows/`**: CI/CD workflows for validation - Les artefacts d'exécution restent hors git et sont référencés depuis les TODO ou rapports.
### `audio/` ## Chemins de référence
Audio content organization: - Architecture: `docs/architecture/index.md`
- **`manifests/`**: YAML manifests describing audio requirements - Runtime: `specs/ZACUS_RUNTIME_3_SPEC.md`
- **`game/prompts/audio/`**: Audio generation prompts (intro, incidents, solutions) - Studio: `specs/STORY_DESIGNER_SCRATCH_LIKE_SPEC.md`
- Pilotage: `plans/master-plan.md`, `todos/master.md`
Validation: `python3 tools/audio/validate_manifest.py`
### `docs/`
Comprehensive documentation:
- **`BRANCHES.md`**: Git branch strategy and workflows
- **`STRUCTURE.md`**: This file - repository architecture
- **`QUICKSTART.md`**: Express setup guide
- **`STYLEGUIDE.md`**: Writing style and tone guidelines
- **`WORKFLOWS.md`**: Development workflows
- **`GLOSSARY.md`**: Terms and definitions
- **`index.md`**: Documentation index
- **`repo-status.md`**: Current repository status
- **`maintenance-repo.md`**: Maintenance procedures
- **`repo-audit.md`**: Audit reports
- **`_generated/`**: Auto-generated documentation
- **`assets/`**: Documentation images and diagrams
### `examples/`
Cross-functional examples and demonstrations. See `examples/README.md` for index.
### `game/`
Core game content (canonical):
- **`scenarios/`**: YAML scenario definitions (single source of truth)
- `zacus_v2.yaml`: Canon scenario with acts, runtime steps and terrain clues
- **`prompts/`**: Generation prompts for various content
- **`exports/`**: Generated exports (not versioned)
Validation: `python3 tools/scenario/validate_scenario.py`
### `hardware/`
Microcontroller firmware and hardware integration:
- **`firmware/`**:
- `esp32/`: ESP32 firmware (main controller, WiFi, audio, story engine)
- `arduino/`: Arduino-compatible variants
- `README.md`: Branch strategy and setup
**Note**: Firmware uses Git branches for development variants instead of separate directories:
- `main:hardware/firmware/` → stable releases
- `hardware/esp32-dev` → active development
- `hardware/esp32-experimental` → R&D
See `hardware/firmware/README.md` for complete details.
### `kit-maitre-du-jeu/`
Game Master materials:
- Minute-by-minute script (ready-to-play)
- Complete solution (culprit, motive, method, timeline)
- Material checklist and 4-station setup
- Role distribution (modular for 6-14 children)
- Anti-chaos guide and station map
- PDF exports
### `ops/`
Operational scripts:
- **`check_scope.sh`**: Validate branch scope compliance
- **`codex_story_ia.sh`**: Story-IA workflow helper
### `printables/`
Printable materials for the game:
- **`src/`**: Source files and generation prompts
- **`export/pdf/`**: Exported PDFs (locally generated, not versioned)
- **`export/png/`**: Exported PNGs (locally generated, not versioned)
- **`manifests/`**: YAML manifests for printables validation
- **`WORKFLOW.md`**: Generation and export workflow
Validation: `python3 tools/printables/validate_manifest.py`
### `scenario-ai-coherence/`
AI-generated scenario coherence artifacts (human-validated):
- **`version-finale/`**: Validated final content
- Future: intermediate versions, generation logs
This folder contains AI-generated scenario content that has been reviewed and approved by human maintainers.
### `tools/`
Python validation and export scripts:
- **`scenario/`**: Scenario validation and export
- `validate_scenario.py`: Validate YAML scenarios
- `export_md.py`: Generate markdown from scenarios
- **`audio/`**: Audio manifest validation
- `validate_manifest.py`: Validate audio manifests
- **`printables/`**: Printables manifest validation
- `validate_manifest.py`: Validate printables manifests
- **`qa/`**: Quality assurance utilities (optional for firmware)
### Root Configuration Files
- **`README.md`**: Main project overview
- **`LICENSE.md`**: Dual licensing (MIT for code, CC BY-NC 4.0 for content)
- **`CONTRIBUTING.md`**: Contribution guidelines
- **`CODEX_RULES.md`**: Branch scope rules for AI assistants
- **`CHANGELOG.md`**: Version history
- **`AGENTS.md`**: AI agent descriptions
- **`SECURITY.md`**: Security policy
- **`CODE_OF_CONDUCT.md`**: Community standards
- **`DISCLAIMER.md`**: Legal disclaimer
- **`.gitignore`**: Git ignore patterns
- **`Makefile`**: Build and validation commands
## Data Flow
```
game/scenarios/*.yaml (CANON)
tools/scenario/validate_scenario.py → validation
tools/scenario/export_md.py → docs/_generated/
printables/src/ + audio/prompts/ → printable PDFs + audio files
kit-maitre-du-jeu/ → complete game master package
```
## Branch Strategy
See `BRANCHES.md` for complete branch workflow. Key branches:
- **`main`**: Stable integration branch
- **`hardware/firmware`**: Firmware development
- **`generation/story-esp`**: Story engine for ESP32
- **`generation/story-ia`**: Game content and scenarios
- **`scripts/generation`**: Validation and export tools
- **`documentation`**: Documentation updates
## Validation Workflow
```bash
# Validate scenario
python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
# Export markdown
python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
# Validate audio manifest
python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
# Validate printables manifest
python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
# Or use Makefile
make scenario-validate
make export
make audio-validate
make printables-validate
make all-validate
```
## Licenses
- **Code** (firmware, scripts, tools): MIT License
- **Creative content** (documents, PDFs, assets, prompts): CC BY-NC 4.0
- See `LICENSE.md` and `LICENSES/` for full text
## Contributing
See `CONTRIBUTING.md` for contribution guidelines and review process.
## Status
Current project status tracked in `docs/repo-status.md`.
+17
View File
@@ -0,0 +1,17 @@
# Agent Matrix
| Agent | Scope | Inputs | Outputs |
| --- | --- | --- | --- |
| PM / Architecture | Global audit, sequencing, dependency map | repo state, plans, docs | `plans/master-plan.md`, architecture maps |
| Runtime / Compiler | Runtime 3 schema, compiler, simulator | YAML scenarios, runtime spec | IR JSON, simulator traces, migration rules |
| Frontend Studio | React + Blockly authoring studio | Runtime 3 contract, API contract | Studio UI, previews, lint/build gates |
| Firmware Adapter | Runtime execution on device | IR JSON, firmware APIs | buildable firmware, runtime adapter |
| Content / Narrative | Scenario migration and feature cards | YAML canon, audio/printables manifests | consistent scenario graph, content backlog |
| Tooling / TUI / Logs | CLI/TUI wrappers and evidence | scripts, CI, artifact dirs | `tools/dev/zacus.sh`, validation entrypoints |
| Docs / Knowledge | Specs, Mermaid maps, READMEs | all workstreams | updated docs, MkDocs site |
| QA / Release | Gates, proofs, release readiness | builds, logs, simulations | validation matrix, release checklist |
## Coordination Rule
- `memory/project-memory.md` stores cross-wave context.
- `plans/agents/*.md` store agent-specific execution plans.
- `todos/*.md` store the next concrete chain of work.
+235
View File
@@ -0,0 +1,235 @@
# AI Integration Map
## Mindmap — AI Capabilities
```mermaid
mindmap
root((Zacus AI))
On-Device
ESP-SR v2.0
WakeNet "Hey Zacus"
MultiNet 50 commands FR
16-bit PCM 16kHz
280 KB PSRAM
ESP-DL v3.2
YOLOv11n INT8
8 prop classes
5-7 FPS 320x240
600 KB PSRAM
Fallback
Tap-to-talk UI
QR code scan
Pre-recorded hints
Server
mascarade LLM
Adaptive hints
Anti-spoiler prompts
Conversation memory
Ollama backend
Coqui XTTS-v2
Voice cloning 6s ref
French TTS
Chunked streaming
Docker container
MCP Server
puzzle_set_state
audio_play
led_set
camera_capture
scenario_advance
GPU KXKM-AI
AudioCraft MusicGen
Room ambients 30-60s
SFX generation
Batch pre-generation
MP3 128kbps output
Fine-tune Pipeline
Qwen2.5-Coder-1.5B
Unsloth + SimPO
GGUF Q4_K_M export
P2P distribute_task
Analytics
Difficulty adaptation
Step timing telemetry
Hint usage tracking
Player count detection
```
## Flowchart — Data Flows
```mermaid
flowchart TD
subgraph Input["Player Input"]
MIC[Microphone I2S]
CAM[Camera OV2640]
BTN[Buttons / Touch]
QR[QR Scanner]
end
subgraph OnDevice["ESP32-S3 On-Device AI"]
SR[ESP-SR v2.0<br/>Wake + Commands]
DL[ESP-DL v3.2<br/>Object Detection]
RT3[Runtime 3 Engine<br/>Step Machine]
AUD[Audio Manager<br/>I2S DMA]
LED[LED Manager<br/>WS2812B]
UI[LVGL Display]
end
subgraph Network["Network Layer"]
WIFI[WiFi HTTP Client]
ESPNOW[ESP-NOW Mesh]
WS[WebSocket Events]
end
subgraph Server["mascarade Server"]
API[mascarade API<br/>/api/v1/send]
MCP[MCP Hardware Server<br/>JSON-RPC 2.0]
TTS[Coqui XTTS-v2<br/>Docker :5002]
OLLAMA[Ollama<br/>:11434]
ADAPT[Adaptive Engine<br/>Difficulty Params]
end
subgraph GPU["KXKM-AI RTX 4090"]
MUSICGEN[AudioCraft MusicGen<br/>Ambient Generation]
STABAUD[Stable Audio Open<br/>SFX Generation]
FINETUNE[Fine-tune Pipeline<br/>Unsloth]
end
subgraph Output["Player Output"]
SPK[Speaker I2S]
LEDS[LED Strips]
SCREEN[LCD Display]
SERVO[Puzzle Actuators]
end
%% Input -> On-Device
MIC -->|PCM 16kHz| SR
CAM -->|320x240 RGB| DL
BTN -->|GPIO events| RT3
QR -->|decoded text| RT3
%% On-Device processing
SR -->|command token| RT3
DL -->|detection event| RT3
RT3 -->|play command| AUD
RT3 -->|color command| LED
RT3 -->|screen update| UI
%% On-Device -> Network
SR -->|complex query| WIFI
RT3 -->|hint request| WIFI
RT3 -->|telemetry| WIFI
RT3 <-->|peer sync| ESPNOW
RT3 -->|state events| WS
%% Network -> Server
WIFI -->|POST /api/v1/send| API
WIFI -->|JSON-RPC| MCP
WS -->|status stream| MCP
%% Server processing
API -->|inference| OLLAMA
OLLAMA -->|hint text| API
API -->|text| TTS
MCP -->|tool dispatch| API
API -->|telemetry| ADAPT
ADAPT -->|params| API
%% Server -> Device
TTS -->|PCM stream| AUD
MCP -->|puzzle cmd| SERVO
MCP -->|led cmd| LED
MCP -->|audio cmd| AUD
%% GPU -> Server (offline)
MUSICGEN -.->|ambient MP3| AUD
STABAUD -.->|SFX WAV| AUD
FINETUNE -.->|GGUF model| OLLAMA
%% Output
AUD --> SPK
LED --> LEDS
UI --> SCREEN
RT3 --> SERVO
%% Styling
style OnDevice fill:#1a3a1a,color:#fff
style Server fill:#1a1a3a,color:#fff
style GPU fill:#3a1a1a,color:#fff
```
## Latency Map
```mermaid
flowchart LR
A["Player speaks"] -->|0ms| B["ESP-SR wake"]
B -->|200ms| C["Command parse"]
C -->|500ms| D{"On-device<br/>or server?"}
D -->|"on-device<br/>0ms"| E["Runtime 3 action"]
D -->|"server<br/>50ms network"| F["mascarade LLM"]
F -->|"2000ms inference"| G["TTS synthesis"]
G -->|"2000ms render"| H["Audio stream"]
H -->|"100ms buffer"| I["Speaker plays"]
E -->|"50ms"| J["LED/Audio/Servo"]
style D fill:#dd6,stroke:#333
```
**Critical path (voice hint)**: 200 + 500 + 50 + 2000 + 2000 + 100 = ~4850 ms worst case, target < 3000 ms.
## Component Integration Matrix
```mermaid
flowchart TD
subgraph Legend
direction LR
L1[Implemented] --- L2[Planned Phase B]
L2 --- L3[Planned Phase C+]
end
subgraph Current["Currently Implemented"]
R3[Runtime 3 Engine]
AUDIO[Audio Manager I2S]
LEDM[LED Manager]
LVGL[LVGL UI]
WIFIC[WiFi + HTTP API]
ESPNM[ESP-NOW Manager]
STOR[Storage Manager]
end
subgraph PhaseB["Phase B: Voice"]
WAKE[WakeNet Custom]
MULTI[MultiNet FR]
XTTS[XTTS-v2 Docker]
STREAM[Audio Streaming]
end
subgraph PhaseC["Phase C+: Vision, LLM, Music"]
YOLO[YOLOv11n ESP-DL]
HINTS[LLM Hints]
MCPS[MCP Server]
MGEN[MusicGen Batch]
end
R3 --> WAKE
R3 --> YOLO
R3 --> HINTS
AUDIO --> STREAM
STREAM --> XTTS
WIFIC --> MCPS
MCPS --> HINTS
HINTS --> XTTS
AUDIO --> MGEN
WAKE --> MULTI
style Current fill:#2d6,stroke:#333
style PhaseB fill:#69d,stroke:#333
style PhaseC fill:#d69,stroke:#333
```
## Notes
- On-device AI (ESP-SR, ESP-DL) runs without network — zero-latency fallback.
- Server AI (LLM, TTS, MCP) requires WiFi — graceful degradation to cached hints.
- GPU AI (AudioCraft) is offline batch only — no runtime dependency.
- All AI features are additive: the base Runtime 3 game works without any AI.
+45
View File
@@ -0,0 +1,45 @@
# Component Map
```mermaid
flowchart LR
subgraph Studio
App["App.tsx"]
Designer["BlocklyDesigner.tsx"]
ScenarioLib["lib/scenario.ts"]
Runtime3Lib["lib/runtime3.ts"]
ApiLib["lib/api.ts"]
end
subgraph Canon
ScenarioYaml["game/scenarios/zacus_v2.yaml"]
end
subgraph Runtime3
CompilerPy["tools/scenario/compile_runtime3.py"]
RuntimeCommon["tools/scenario/runtime3_common.py"]
SimPy["tools/scenario/simulate_runtime3.py"]
end
subgraph Device
Firmware["hardware/firmware"]
StoryCore["lib/story runtime adapter"]
UI["Freenove UI + API"]
end
ScenarioYaml --> ScenarioLib
Designer --> ScenarioLib
ScenarioLib --> Runtime3Lib
Runtime3Lib --> App
ScenarioYaml --> CompilerPy
CompilerPy --> RuntimeCommon
RuntimeCommon --> SimPy
RuntimeCommon --> Firmware
Firmware --> StoryCore
StoryCore --> UI
ApiLib --> UI
```
## Notes
- The React studio owns authoring ergonomics and previews.
- The Python compiler/simulator owns deterministic offline validation.
- Firmware consumes the Runtime 3 contract and exposes runtime APIs.
+21
View File
@@ -0,0 +1,21 @@
# Data Flow Map
```mermaid
flowchart TD
Author["Author edits Blockly graph"] --> Draft["Scenario draft in browser"]
Draft --> Yaml["Canonical YAML"]
Yaml --> Validate["Scenario validators"]
Yaml --> Compile["Runtime 3 compiler"]
Compile --> IR["Runtime 3 JSON"]
IR --> Sim["Local simulator"]
IR --> Firmware["Firmware adapter"]
Yaml --> Exports["Audio / printables / MJ kit exports"]
Firmware --> Api["Runtime API + status"]
Api --> Studio["Studio dashboard + diagnostics"]
Api --> Evidence["Logs / artifacts / AGENT_TODO evidence"]
```
## Guarantees
- Narrative truth remains in YAML during the migration window.
- Runtime truth is emitted as versioned IR JSON.
- Hardware evidence stays outside git and is referenced from planning/TODO docs.
+34
View File
@@ -0,0 +1,34 @@
# Feature Map
## Core Gameplay Features
| Feature | Canon input | Runtime pivot | Delivery surfaces |
| --- | --- | --- | --- |
| LA 440 stabilization | `puzzles.PUZZLE_LA_440` | `STEP_LA_DETECTOR` | studio, firmware, audio, GM kit |
| LEFOU piano unlock | `puzzles.PUZZLE_PIANO_ALPHABET_5` | `STEP_LEFOU_DETECTOR` | studio, firmware, printables, GM kit |
| QR WIN finale | `puzzles.PUZZLE_QR_WIN` | `STEP_QR_DETECTOR` | studio, firmware camera, archives setup |
| Media hub / finale | `firmware.media_hub` | `SCENE_MEDIA_MANAGER` | firmware, studio dashboard |
## Authoring Features
```mermaid
mindmap
root((Studio Zacus))
Blockly editor
Steps
Transitions
Apps
YAML preview
Runtime 3 preview
Validate
Deploy
Dashboard
Media
Network
```
## Refactor Priorities
1. Authoring parity with the current canonical scenario.
2. Runtime 3 compilation and simulation.
3. Combined-board execution on Freenove.
4. Documentation and cleanup of legacy references.
+33
View File
@@ -0,0 +1,33 @@
# Zacus Architecture
Cette section est le point dentrée canonique de la refonte Zacus V3.
## Vue densemble
- Le contenu narratif reste défini dans `game/scenarios/*.yaml`.
- Le studio auteur canonique est `frontend-scratch-v2/`.
- Le nouveau noyau cible est **Zacus Runtime 3**, compilé en IR JSON.
- Le firmware devient un adaptateur dexécution et dAPI.
## Cartes publiées
- [System Map](system-map.md)
- [Component Map](component-map.md)
- [Data Flow Map](data-flow-map.md)
- [Feature Map](feature-map.md)
- [Migration Map](migration-map.md)
- [Agent Matrix](agent-matrix.md)
- [Release Map](release-map.md)
## Principe directeur
Une seule architecture visible:
```mermaid
flowchart LR
Author["Auteur Zacus"] --> Studio["Studio React + Blockly"]
Studio --> Canon["YAML canonique"]
Canon --> Compiler["Compilateur Runtime 3"]
Compiler --> IR["IR JSON versionné"]
IR --> Firmware["Adaptateur firmware"]
Firmware --> Device["Carte Freenove / périphériques"]
Firmware --> API["API runtime + observabilité"]
API --> Studio
```
+23
View File
@@ -0,0 +1,23 @@
# Migration Map
## Migration Route
```mermaid
flowchart LR
LegacyDocs["Conflicting docs/specs"] --> CanonDocs["Unified architecture docs"]
LegacyFront["fronted dev web UI"] --> ReactStudio["frontend-scratch-v2"]
StoryV2["Story V2 runtime"] --> Runtime3["Zacus Runtime 3"]
DirectFirmwareStory["Firmware-owned story semantics"] --> FirmwareAdapter["Firmware adapter over IR"]
AdHocTracking["Scattered TODO/plan refs"] --> RefactorCoord["memory/ + plans/ + todos/"]
```
## Rules
- No destructive cleanup before replacement proof.
- Legacy routes are quarantined first, then removed.
- Hardware evidence stays in `hardware/firmware/docs/AGENT_TODO.md` until the adapter route is stable.
## Current Quarantine Targets
- `fronted dev web UI/`
- Svelte/Cytoscape specs and references
- `zacus_v1` references outside archive or historical context
- duplicate docs such as `docs/AGENTS 2.md` and `docs/AGENT_TODO 2.md`
+20
View File
@@ -0,0 +1,20 @@
# Release Map
```mermaid
flowchart LR
Wave0["Wave 0 Baseline"] --> Wave1["Wave 1 Canon"]
Wave1 --> Wave2["Wave 2 Runtime 3"]
Wave2 --> Wave3["Wave 3 Studio"]
Wave3 --> Wave4["Wave 4 Firmware"]
Wave4 --> Wave5["Wave 5 Cleanup"]
Wave5 --> Wave6["Wave 6 Cutover"]
```
## Exit Criteria
- Wave 0: baseline evidence captured and dirty-tree risks understood.
- Wave 1: architecture/spec/docs/plans canonized.
- Wave 2: Runtime 3 compiler and simulator usable on the canonical scenario.
- Wave 3: React/Blockly studio builds and previews Runtime 3.
- Wave 4: Freenove build path consumes the runtime contract without regression.
- Wave 5: legacy paths archived or deleted with proof of replacement.
- Wave 6: README, docs site, CI, and operator entrypoints all point to the new canon.
+48
View File
@@ -0,0 +1,48 @@
# System Map
## Canonical System
```mermaid
flowchart TD
subgraph Authoring
YAML["game/scenarios/zacus_v2.yaml"]
Blockly["frontend-scratch-v2 Blockly graph"]
end
subgraph Runtime3
Compiler["compile_runtime3.py / runtime3.ts"]
IR["Runtime 3 IR JSON"]
Sim["simulate_runtime3.py"]
end
subgraph Delivery
Audio["audio/manifests"]
Printables["printables/manifests"]
Kit["kit-maitre-du-jeu"]
Docs["docs + MkDocs"]
end
subgraph Device
Firmware["hardware/firmware adapter"]
Board["freenove_esp32s3"]
Media["media / network / UI"]
end
YAML --> Compiler
Blockly --> Compiler
Compiler --> IR
IR --> Sim
IR --> Firmware
YAML --> Audio
YAML --> Printables
YAML --> Kit
YAML --> Docs
Firmware --> Board
Firmware --> Media
```
## Design Notes
- YAML stays canonical for narrative truth during migration.
- Blockly is the preferred authoring UX, not the runtime artifact itself.
- Runtime 3 IR is the portable contract between authoring and execution.
- Firmware owns board integration, not story semantics.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

+31
View File
@@ -0,0 +1,31 @@
# OSS Benchmark
## Adopt / Align
| Tool | Why it matters | Reference |
| --- | --- | --- |
| Blockly | Canonical block-based authoring model for the Zacus studio. | https://developers.google.com/blockly |
| Mermaid | Lightweight diagrams embedded in docs and specs. | https://mermaid.js.org/ |
| Material for MkDocs | Fast documentation shell for the rebuilt knowledge base. | https://squidfunk.github.io/mkdocs-material/ |
| Charm Gum | Optional shell TUI layer with graceful fallback. | https://github.com/charmbracelet/gum |
## Benchmark / Inspiration
| Project | Why it is relevant | Reference |
| --- | --- | --- |
| Node-RED | Flow-based authoring patterns and graph ergonomics. | https://nodered.org/ |
| Ink | Narrative authoring and branching conventions. | https://www.inklestudios.com/ink/ |
| Yarn Spinner | Dialogue/runtime separation patterns. | https://www.yarnspinner.dev/ |
| LVGL | Embedded UI runtime constraints and display patterns. | https://lvgl.io/ |
| SquareLine Studio | Embedded UI workflow inspiration for screen authoring. | https://squareline.io/ |
| EEZ Studio | Embedded UI/editor workflow benchmark for device tooling. | https://www.envox.eu/studio/studio-introduction/ |
## Zacus-specific Decision
- Zacus Runtime 3 remains custom and portable.
- External OSS is used as a reference or shell, not as a drop-in replacement for the story runtime.
## Fresh Notes (2026-03-16)
- Blockly remains the strongest fit for Zacus authoring because it is still positioned as a customizable client-side block editor for application-specific languages.
- Node-RED remains the best ergonomics benchmark for event-driven flows that can run both on low-cost edge devices and in the cloud.
- LVGL stays the right embedded UI reference because it explicitly targets small memory footprints and broad MCU/OS/display portability.
- EEZ Studio is the strongest workflow benchmark for embedded authoring because it combines templates, visual debugging, execution logs, and timeline-driven UI tooling.
+24 -112
View File
@@ -1,119 +1,31 @@
--- # Zacus V3
layout: default
title: Le Mystère du Professeur Zacus
description: Une enquête scientifique immersive pour anniversaire (printables, audio, modules électroniques).
---
# 🎩 Le Mystère du Professeur Zacus Point d'entrée documentaire de la refonte Zacus.
![Couverture](./assets/cover.png) ## Canon
- YAML source de vérité: `game/scenarios/zacus_v2.yaml`
- Studio auteur: `frontend-scratch-v2/`
- Runtime portable: Zacus Runtime 3
- Cible terrain: Freenove ESP32-S3 via `hardware/firmware/`
Le Mystère du Professeur Zacus : une enquête scientifique immersive pour anniversaire, jouable en famille ou entre ami·e·s. ## Démarrage
- [Quickstart](QUICKSTART.md)
- [Architecture](architecture/index.md)
- [Benchmark OSS](benchmark-oss.md)
Scénarios modulaires (YAML), supports imprimables, audio, et accessoires électroniques (ESP32/Arduino). ## Carte rapide
<div class="badges"> ```mermaid
<div class="badge"><img src="./assets/icons/icon_hat.png" alt="hat" />Scénarios modulaires</div> flowchart LR
<div class="badge"><img src="./assets/icons/icon_audio.png" alt="audio" />Audio & checkpoints</div> YAML["Scenario YAML"] --> Runtime3["Runtime 3"]
<div class="badge"><img src="./assets/icons/icon_chip.png" alt="chip" />Modules électroniques</div> Runtime3 --> Studio["React + Blockly studio"]
<div class="badge"><img src="./assets/icons/icon_flask.png" alt="flask" />Thème labo/science</div> Runtime3 --> Firmware["Firmware adapter"]
</div> YAML --> Exports["Audio / printables / MJ kit"]
---
## Structure du projet
```text
├── game/scenarios/*.yaml Scénarios (source de vérité)
├── audio/manifests/*.yaml Manifeste audio + fichiers associés
├── printables/manifests/*.yaml Manifeste printables + PDF/PNG
├── hardware/firmware/esp32/ Firmware (lecture seule sans approbation)
├── tools/ Outils Python (validation, export, génération)
├── docs/ Guides + mini-site GitHub Pages
├── assets/ Images (README / marketing)
``` ```
--- ## Liens utiles
- [Repository Structure](STRUCTURE.md)
## Démarrage rapide - Spécification Runtime 3: `specs/ZACUS_RUNTIME_3_SPEC.md`
- Spécification studio: `specs/STORY_DESIGNER_SCRATCH_LIKE_SPEC.md`
1. Imprime les printables (cartes, indices). - Plans: `plans/master-plan.md`
2. (Option) Flash les modules électroniques. - Todos: `todos/master.md`
3. Lis le scénario YAML et place les indices.
4. Lance la partie et suis le guide MJ.
---
## Guides & documentation
- [STRUCTURE.md](STRUCTURE.md) : Architecture détaillée
- [QUICKSTART.md](QUICKSTART.md) : Démarrage express
- [WORKFLOWS.md](WORKFLOWS.md) : Workflows validation/export
- [faq.md](faq.md) : FAQ dépannage
---
## Licences
- Code : MIT — voir [../LICENSE](../LICENSE)
- Contenu créatif : CC BYNC 4.0 — voir [../LICENSE-CONTENT.md](../LICENSE-CONTENT.md)
---
## Assets
- Couverture : assets/cover.png
- OpenGraph : assets/og.png
- Poster : assets/poster.png
- Logo : assets/logo.png
- Diagramme : assets/diagram.png
- Aperçu printables : assets/printables.png
## Démo
![Démo](./assets/demo.gif)
---
## Jouer (MJ)
### Checklist rapide
1. Imprime les **printables** (cartes, indices).
2. (Option) Flash les modules électroniques.
3. Lis le scénario et place les indices.
4. Lance la partie et suis le guide MJ.
👉 Voir aussi : [FAQ (flash / SD / audio)](./faq)
---
## Développer / contribuer
### Structure du dépôt
- `game/scenarios/` — scénarios (source de vérité)
- `audio/` — manifestes et ressources audio
- `printables/` — manifestes + PDF/PNG
- `tools/` — outils Python (validation, export)
- `hardware/firmware/esp32/` — firmware (**lecture seule** sans approbation)
### Démarrage rapide
```bash
python3 -m pip install -r tools/requirements.txt
python3 tools/scenario/validate_scenario.py game/scenarios/mon_scenario.yaml
```
---
## Visuels & matériel
![Prototype](./assets/board.jpg)
![Printables](./assets/printables.png)
![Diagramme](./assets/diagram.png)
---
## Licences
- **Code** : MIT (`LICENSE`)
- **Contenu** : CC BYNC 4.0 (`LICENSE-CONTENT.md`)
-67
View File
@@ -1,67 +0,0 @@
# Audit de cohérence du dépôt
Date: 2026-02-12 (mise à jour cohérence globale)
Périmètre: **contenu documentaire + structure fichiers**, sans modification firmware.
## Résumé exécutif
- Le dépôt est globalement bien structuré en 3 piliers: `kit-maitre-du-jeu/`, `printables/`, `hardware/`.
- La zone la plus mature côté exécution est le firmware ESP32/ESP8266 (documenté et outillé).
- Le principal point de cohérence identifié concernait les chemins printables documentés mais absents.
- Les images PNG printables sont désormais centralisées sous `printables/export/png/`.
## Couverture de revue
- Revue des fichiers Markdown/README du dépôt (hors licences externes inchangées).
- Vérification de cohérence des chemins documentés vs arborescence versionnée.
- Vérification des assets image versionnés.
## Analyse du contenu des fichiers
### 1) Documentation racine
- `README.md` décrit clairement le périmètre et les licences.
- `CONTRIBUTING.md` couvre les règles éditoriales et le workflow.
- `CHANGELOG.md` contient désormais une release `0.2.0` et un `Unreleased` exploitable.
### 2) Kit MJ
- Le dossier `kit-maitre-du-jeu/` est cohérent et orienté animation terrain.
- Les sous-docs couvrent script, solution, rôles, anti-chaos et stations.
### 3) Printables
- La convention de nommage est explicite.
- Le dépôt déclarait une structure `src/export` non matérialisée: ce point est désormais aligné.
### 4) Hardware
- Arborescence hardware lisible (`bom`, `wiring`, `firmware`, `enclosure`).
- Le firmware reste hors périmètre de modification dans ce cycle.
## Analyse des images
Commande utilisée:
- `rg --files | rg -i '\\.(png|jpe?g|gif|webp|svg|bmp)$'`
Résultat:
- Des previews PNG sont bien versionnées, avec un classement thématique dans `printables/export/png/`.
Conséquence:
- La revue visuelle des imprimables est possible directement depuis le dépôt.
- Recommandé: maintenir ce classement à chaque nouvel export.
## Corrections de cohérence appliquées
1. Création de la structure printables attendue:
- `printables/src/`
- `printables/export/pdf/`
- `printables/export/png/`
2. Création des sous-dossiers invitations cohérents avec son README:
- `printables/invitations/src/`
- `printables/invitations/export/pdf/`
- `printables/invitations/export/png/`
3. Ajout de README ciblés dans les nouveaux dossiers printables.
4. Regroupement des PNG du dossier `printables/` vers `printables/export/png/` avec sous-dossiers (`general`, `fiche-enquete`, `personnages`, `zones`).
## Recommandations immédiates (hors firmware)
- Ajouter 1 à 3 documents pilotes dans `printables/export/pdf/` et leurs previews dans `printables/export/png/`.
- Maintenir `CHANGELOG.md` à chaque PR doc-only.
- Utiliser la checklist PR de `CONTRIBUTING.md` comme gate de merge.
-54
View File
@@ -1,54 +0,0 @@
# Analyse approfondie du dépôt — 2026-02-25
## Objectif
Aller plus loin que le lot conversation bundle en évaluant l'état global du repo (docs, workflows, contenu gameplay, outillage validation, CI).
## Méthode
Commandes de revue utilisées (échantillon):
- `rg --files`
- `rg -n "validate_|workflow|scenario" .github/workflows tools docs game`
- `python3 tools/scenario/validate_runtime_bundle.py`
- `python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml`
- `python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml`
- `python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml`
- `python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml`
## Constat global
### 1) Source de vérité gameplay
-`game/scenarios/zacus_v1.yaml` est bien utilisé comme source canon.
- ✅ Les exports MJ/docs générés sont cohérents avec le canon courant.
- ⚠️ Le bundle `scenario-ai-coherence/` reste parallèle: utile pour itérer, mais il faut éviter toute dérive silencieuse.
### 2) Gates G3 / validation
- ✅ Validateurs scénario/audio/printables présents et opérationnels.
-`validate_runtime_bundle.py` existe et est branché dans CI (`.github/workflows/validate.yml`).
- ⚠️ Le diff fonctionnel gameplay est scripté localement, mais pas encore exécuté automatiquement en CI.
### 3) Documentation et guidage équipe
-`docs/WORKFLOWS.md` décrit l'enchaînement validation.
- ✅ Spec/arch conversation bundle documentent le périmètre.
- ⚠️ Il manque un guide "release gate" unique qui enchaîne G0→G5 en une seule checklist opérationnelle.
### 4) Risques principaux
- Divergence entre bundle conversationnel et canon gameplay si la promotion n'est pas rituelle.
- G4/G5 encore partiels (pas de lot release/compliance structuré dans ce cycle).
- Dépendance implicite à PyYAML: bootstrap présent, mais doit rester obligatoire en CI locale/README.
## Plan de progression recommandé (prochaines PRs)
### PR-A (QA/Test)
- Ajouter un job CI dédié qui exécute aussi `tools/scenario/diff_gameplay_scenarios.py`.
- Publier l'artifact markdown de diff en sortie CI (upload artifact GitHub Actions).
### PR-B (Doc/Release)
- Créer `docs/RELEASE_GATES.md` avec checklist G0→G5 exécutable.
- Standardiser le template de PR pour exiger la section "Gates satisfaites" + "limitations".
### PR-C (Compliance)
- Ajouter un inventaire SBOM/licences minimal pour G5 (au moins dépendances Python validateurs + manifest repo).
- Centraliser les preuves dans `evidence/` avec index daté.
## Décision proposée maintenant
- Garder le canon gameplay tel quel.
- Passer sur PR-A en priorité pour fermer l'écart CI sur le diff fonctionnel.
-32
View File
@@ -1,32 +0,0 @@
# Repo Status Report
## Tree summary
- `kit-maitre-du-jeu/`: full GM script, solutions, checklists, and export folders for PDF/PNG deliverables.
- `printables/`: `src/` plus `export/{pdf,png}/` for invitations, cards, badges, and the one-page rule set.
- `hardware/`: BOMs, wiring docs, and the ESP32/Arduino firmware flow with `.pio` dependencies (libraries such as Adafruit and ESP8266Audio naturally expand here).
- `docs/`: maintenance plan, repo audit, and asset folder (`docs/assets/` contains the repo map SVG already referenced in `README.md`).
- `examples/` and `scenario-ai-coherence/` (renamed for portability): auxiliary content and tools, now safe for Windows/CI.
## Empty / TODO files
- `printables/invitations/export/pdf/.gitkeep`
- `printables/invitations/export/png/.gitkeep`
- `printables/invitations/src/.gitkeep`
- assorted `.nojekyll`, `.uno.test.skip`, and upstream library placeholders under `hardware/firmware/esp32/.pio/` (e.g., Adafruit BusIO examples); none appear to contain TODO hints, but they are empty files that may surprise contributors or scripts.
## Broken links
- `hardware/firmware/esp32/.pio/libdeps/esp32dev/ESP8266Audio/README.md` line 250 → `examples/StreamMP3FromHTTP_SPIRAM/Schema_Spiram.png` (missing in that vendor snapshot).
- `hardware/firmware/esp32/.pio/libdeps/esp32_release/ESP8266Audio/README.md` line 250 → same missing `Schema_Spiram.png`.
- `hardware/firmware/esp32/.pio/libdeps/esp32dev/Mozzi/README.md` line 116 → `extras/NEWS.txt` (not included in the copied tree).
- `hardware/firmware/esp32/.pio/libdeps/esp32_release/Mozzi/README.md` line 116 → same missing `extras/NEWS.txt`.
## Naming / portability issues
- `scenario-ai-coherence/` and `scenario-ai-coherence/version-finale/` now avoid spaces/colon, keeping the tree stable sur Windows/CI tooling. Continue to prefer hyphenated, lowercase identifiers pour tout nouveau sous-dossier.
## Licences
- `README.md`, `CONTRIBUTING.md`, and `LICENSE.md` sont alignés autour du modèle CC BY-NC 4.0 pour les contenus créatifs et MIT pour le code (`LICENSES/CC-BY-NC-4.0.txt`, `LICENSES/MIT.txt`). Garder la même nomenclature accélère la revue des contributions.
## Patch plan
- Continuer à surveiller les fichiers vides (`.gitkeep`, `.nojekyll`, `.uno.test.skip`) pour décider sils restent ou disparaissent lors dun nettoyage futur.
- Corriger ou supprimer les liens cassés des bibliothèques `ESP8266Audio`/`Mozzi` si les assets nécessaires ne sont plus plastifiés.
- Valider chaque nouvelle version de scénario et de manifeste audio avec `tools/scenario/validate_scenario.py` et `tools/audio/validate_manifest.py`.
- Mettre à jour Quickstart, Styleguide et le workflow printables si la structure du kit ou les fichiers audio changent.
-52
View File
@@ -1,52 +0,0 @@
{
"schema_version": "repo_state.v1",
"generated_at_utc": "2026-02-21T02:23:01Z",
"repo": "le-mystere-professeur-zacus",
"repo_url": "https://github.com/electron-rare/le-mystere-professeur-zacus.git",
"branch": "codex/repo-state-zacus",
"head": "3fec631c43c66a6ae12c12d2f652c8800d22b28f",
"head_date": "2026-02-21T02:59:01+01:00",
"head_subject": "feat(freenove): stabilize fallback AP outside local wifi (#103)",
"project_kind": "hardware_firmware_hybrid",
"pivot_changes": [
{
"path": "hardware/firmware/data/story/apps/APP_WIFI.json",
"tags": [
"firmware_build_test",
"hardware_validation"
]
},
{
"path": "hardware/firmware/docs/AGENT_TODO.md",
"tags": [
"firmware_build_test",
"hardware_validation"
]
},
{
"path": "hardware/firmware/hardware/firmware/ui_freenove_allinone/include/network_manager.h",
"tags": [
"firmware_build_test",
"hardware_validation"
]
},
{
"path": "hardware/firmware/hardware/firmware/ui_freenove_allinone/src/main.cpp",
"tags": [
"firmware_build_test",
"hardware_validation"
]
},
{
"path": "hardware/firmware/hardware/firmware/ui_freenove_allinone/src/network_manager.cpp",
"tags": [
"firmware_build_test",
"hardware_validation"
]
}
],
"impact_gates": [
"firmware_build_test",
"hardware_validation"
]
}
+299
View File
@@ -0,0 +1,299 @@
# Voice Pipeline Guide — Professeur Zacus
Technical guide for integrating voice interaction into the Zacus ESP32-S3 installation.
## Overview
The voice pipeline enables visitors to interact with Professeur Zacus using natural speech. The system uses a split architecture: wake word detection runs locally on the ESP32-S3 (via ESP-SR), while speech-to-text, LLM inference, and text-to-speech run server-side via the mascarade bridge.
## Hardware Requirements
### INMP441 MEMS Microphone
| INMP441 Pin | ESP32-S3 GPIO | Notes |
|-------------|---------------|-------|
| SCK (BCLK) | GPIO 41 | I2S0 bit clock |
| WS (LRCLK) | GPIO 42 | I2S0 word select |
| SD (DATA) | GPIO 2 | I2S0 data in |
| L/R | GND | Left channel select |
| VDD | 3.3V | Power supply |
| GND | GND | Ground |
**Important:** The microphone uses I2S0, which is a separate bus from the speaker output on I2S1 (MAX98357A). Both can operate simultaneously for full-duplex audio.
### Speaker (already present)
The Freenove All-in-One board includes a MAX98357A I2S amplifier on I2S1. No additional wiring needed for audio output.
### Bill of Materials (voice-specific)
| Part | Qty | Approx. Cost | Source |
|------|-----|-------------|--------|
| INMP441 breakout | 1 | 3-5 EUR | AliExpress, Amazon |
| Dupont wires (F-F) | 5 | included | — |
## Software Requirements
### ESP-IDF + esp-sr Component
The ESP-SR library (WakeNet, MultiNet, AFE) requires the ESP-IDF framework. The current project uses Arduino framework via PlatformIO.
**Option A: Full migration to ESP-IDF (recommended for production)**
```ini
; platformio.ini changes
[env:freenove_allinone]
framework = espidf
; All Arduino-style code must be rewritten to ESP-IDF APIs
```
**Option B: Arduino as ESP-IDF component (gradual migration)**
```ini
; platformio.ini changes
[env:freenove_allinone]
framework = arduino, espidf
; Allows mixing Arduino and ESP-IDF code
; esp-sr can be added as an IDF component
```
### esp-sr Component Registration
Create or update `idf_component.yml`:
```yaml
dependencies:
espressif/esp-sr:
version: "^1.0.0"
# Includes: WakeNet, MultiNet, AFE (AEC + NS + VAD)
```
### Partition Table
ESP-SR models need flash space. Update `partitions.csv`:
```
# Name, Type, SubType, Offset, Size, Flags
model, data, spiffs, , 1500K, ; WakeNet + optional MultiNet models
```
## Voice Flow — Sequence Diagram
```mermaid
sequenceDiagram
participant Mic as INMP441<br/>(I2S0)
participant AFE as ESP-SR AFE<br/>(AEC+NS+VAD)
participant WN as WakeNet9<br/>("Prof Zacus")
participant WS as WebSocket<br/>Client
participant Bridge as mascarade<br/>bridge:4001
participant LLM as LLM<br/>(Qwen/GPT)
participant TTS as Piper TTS<br/>(French)
participant Spk as Speaker<br/>(I2S1)
Note over Mic,AFE: LISTENING state
Mic->>AFE: 16kHz 16-bit PCM (continuous)
AFE->>WN: Cleaned audio frames
WN-->>WN: Scanning for wake word...
Note over WN,WS: WAKE_DETECTED state
WN->>WS: Wake word detected!
Spk->>Spk: Play chime / LED feedback
Note over WS,Bridge: STREAMING state
WS->>Bridge: {"type":"hello","wakeword":"professeur zacus"}
loop Until VAD silence
AFE->>WS: Processed audio frames
WS->>Bridge: OPUS-encoded binary frames
Bridge-->>WS: {"type":"stt","text":"..."} (interim)
end
WS->>Bridge: {"type":"end_of_speech"}
Note over Bridge,LLM: PROCESSING state
Bridge->>LLM: Final transcription -> LLM prompt
LLM->>Bridge: Response text (streamed)
Note over Bridge,Spk: SPEAKING state
Bridge->>TTS: Response text
TTS->>Bridge: OPUS audio stream
Bridge->>WS: {"type":"tts","state":"start"}
loop TTS audio chunks
Bridge->>WS: OPUS-encoded binary frames
WS->>Spk: Decode OPUS -> PCM -> I2S1
end
Bridge->>WS: {"type":"tts","state":"end"}
Note over Mic,Spk: Back to LISTENING
```
## XiaoZhi WebSocket Protocol
The voice pipeline follows the [XiaoZhi-ESP32](https://github.com/78/xiaozhi-esp32) WebSocket protocol, which is well-documented and battle-tested.
### Client -> Server Messages
| Type | Format | Description |
|------|--------|-------------|
| Hello | `{"type":"hello","version":1,"wakeword":"..."}` | Session start after wake detection |
| Audio | Binary (OPUS frames) | 20ms OPUS-encoded audio at 16kHz mono |
| Abort | `{"type":"abort"}` | Cancel current interaction |
| End of speech | `{"type":"end_of_speech"}` | VAD detected end of utterance |
### Server -> Client Messages
| Type | Format | Description |
|------|--------|-------------|
| STT result | `{"type":"stt","text":"..."}` | Interim/final transcription |
| LLM text | `{"type":"llm","text":"...","emotion":"..."}` | Streamed LLM response |
| TTS control | `{"type":"tts","state":"start\|end"}` | TTS playback boundaries |
| TTS audio | Binary (OPUS frames) | OPUS-encoded speech audio |
| Emotion | `{"type":"emotion","value":"happy\|thinking\|..."}` | Facial expression hint |
### Audio Encoding
- **Codec:** OPUS (RFC 6716)
- **Frame size:** 20ms
- **Sample rate:** 16kHz
- **Channels:** Mono
- **Bitrate:** ~16kbps (voice-optimized)
- **Library:** `libopus` via ESP-IDF component or bundled in esp-sr
## Server-Side Requirements
### mascarade Bridge Endpoint
The mascarade bridge (running on GrosMac or VM) needs a `/voice` WebSocket endpoint that:
1. Receives OPUS audio frames from ESP32
2. Decodes OPUS -> PCM
3. Runs STT (Whisper via faster-whisper or whisper.cpp)
4. Sends transcription to LLM pipeline (existing mascarade flow)
5. Sends LLM response text to TTS
6. Encodes TTS output as OPUS
7. Streams OPUS frames back to ESP32
This endpoint does not exist yet in mascarade. It will be added as a new route in the bridge service.
### TTS Services
Two TTS options are provided via Docker Compose (`tools/dev/docker-compose.tts.yml`):
**Piper TTS (recommended for low latency)**
- CPU-only, runs on VM (192.168.0.119)
- French voice: `fr_FR-siwis-medium` or `fr_FR-upmc-medium`
- Latency: ~200ms for short sentences
- OpenAI-compatible API on port 8000
**Coqui XTTS-v2 (for voice cloning)**
- GPU required, deploy on KXKM-AI (RTX 4090)
- Can clone Professeur Zacus's voice from ~30s of sample audio
- Higher quality but higher latency (~1-2s)
- API on port 5002
### STT Service
- **Recommended:** faster-whisper with `large-v3` model
- **Language:** French (`--language fr`)
- **Deploy on:** KXKM-AI for GPU inference, or VM for CPU (slower)
- **Alternative:** whisper.cpp for CPU-optimized inference
## Custom Wake Word — Espressif Process
To get a custom "Professeur Zacus" wake word model for WakeNet9:
1. **Contact Espressif** via https://www.espressif.com/en/contact-us
2. **Provide:**
- Target phrase: "Professeur Zacus"
- Language: French
- 200+ audio recordings of the phrase (diverse speakers, environments)
- Target platform: ESP32-S3 with WakeNet9
3. **Timeline:** 2-4 weeks
4. **Cost:** Varies (may be free for open-source/educational projects)
5. **Delivery:** `.wn9` model file to flash to partition
### Recording Tips for Training Data
- Record in the target environment (exhibition space)
- Include male/female voices, different ages
- Include background noise variations
- Record at 16kHz, 16-bit, mono WAV
- Minimum 200 recordings, ideally 500+
- Use the INMP441 mic itself for best acoustic match
## French Voice Command Strategy
**Local MultiNet is NOT recommended for French** because:
- MultiNet only supports Chinese, English, and a few other languages
- French phonetics are complex (liaisons, nasals, silent letters)
- Limited command vocabulary even in supported languages
**Strategy: Server-side ASR for all speech recognition**
1. Wake word detection: Local (WakeNet9, language-agnostic acoustic model)
2. All speech-to-text: Server-side via Whisper (excellent French support)
3. Intent parsing: LLM-based via mascarade (natural language understanding)
4. No local MultiNet needed — everything after wake word goes to server
This simplifies the ESP32 firmware (no MultiNet model in flash) and gives unlimited French vocabulary through the LLM.
## Migration Path — Arduino to ESP-IDF
### Phase 1: Scaffold (current)
- Voice pipeline header + stubs in Arduino framework
- No ESP-SR dependency
- Compiles and runs (functions return safe defaults)
### Phase 2: Arduino-as-Component
- Switch PlatformIO to `framework = arduino, espidf`
- Add esp-sr as IDF component
- Implement I2S mic input and AFE initialization
- Test wake word detection with "Hi Lexin" placeholder
- Keep all existing Arduino code unchanged
### Phase 3: WebSocket Streaming
- Add OPUS encoder (IDF component)
- Implement WebSocket client for mascarade bridge
- Stream audio on wake detection
- Receive and play TTS responses
### Phase 4: Production
- Order custom "Professeur Zacus" wake word
- Full ESP-IDF migration (optional, for memory optimization)
- AEC tuning (echo cancellation between speaker and mic)
- LED/display feedback during voice states
## Memory Budget
| Component | RAM Type | Size | Notes |
|-----------|----------|------|-------|
| WakeNet9 | PSRAM | ~340 KB | Single wake word model |
| AFE (1ch) | PSRAM | ~1.1 MB | AEC + noise suppression + VAD |
| OPUS codec | Internal | ~50 KB | Encoder + decoder |
| Audio buffers | PSRAM | ~64 KB | Ring buffers for I2S + WebSocket |
| **Total voice** | **Mixed** | **~1.5 MB** | |
| LVGL + UI | PSRAM | ~2 MB | Existing allocation |
| Camera | PSRAM | ~500 KB | JPEG buffer |
| **System total** | **PSRAM** | **~4 MB** | Of 8 MB available |
## File Structure
```
ESP32_ZACUS/ui_freenove_allinone/
├── include/voice/
│ └── voice_pipeline.h # Public API (this scaffold)
├── src/voice/
│ └── voice_pipeline.cpp # Stub implementation
└── ...
docs/voice/
└── VOICE_PIPELINE_GUIDE.md # This document
tools/dev/
└── docker-compose.tts.yml # TTS services (Piper + XTTS-v2)
```
## References
- [XiaoZhi-ESP32](https://github.com/78/xiaozhi-esp32) — Open-source voice assistant for ESP32-S3
- [ESP-SR Programming Guide](https://docs.espressif.com/projects/esp-sr/en/latest/) — WakeNet, MultiNet, AFE
- [Piper TTS](https://github.com/rhasspy/piper) — Fast local TTS with French models
- [Coqui XTTS-v2](https://github.com/coqui-ai/TTS) — Voice cloning TTS
- [faster-whisper](https://github.com/guillaumekln/faster-whisper) — CTranslate2-based Whisper
- [OPUS codec](https://opus-codec.org/) — Low-latency audio codec
-4
View File
@@ -1,4 +0,0 @@
VITE_ZACUS_STUDIO_AI_URL=http://127.0.0.1:8787/story_generate
VITE_API_BASE=http://192.168.0.91
VITE_API_PROBE_PORTS=80,8080
VITE_API_FLAVOR=auto
-14
View File
@@ -1,14 +0,0 @@
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements
- [x] Scaffold the Project
- [x] Customize the Project
- [x] Install Required Extensions
- [x] Compile the Project
- [x] Create and Run Task
- [x] Launch the Project
- [x] Ensure Documentation is Complete
Notes:
- Project: Vite + React + TypeScript + Tailwind.
- Dev server: npm run dev
- Build: npm run build
-33
View File
@@ -1,33 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build artifacts
.pio
.platformio
build
.venv
node_modules
dist
dist-ssr
.svelte-kit
playwright-report
test-results
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-120
View File
@@ -1,120 +0,0 @@
---
# Zacus WebUI (Story V2)
// TODO NO DEV FINISH (need KILL_LIFE ?)
Frontend Mission Control pour les devices Zacus.
---
## 📝 Description
Interface web de pilotage, design et diagnostic pour les firmwares Zacus (Story V2 et legacy).
---
## 📦 Fonctionnalités principales
- Sélecteur de scénario avancé (recherche, tri, mode legacy, CTA adaptés)
- Orchestrateur live (statut runtime, recovery, filtres, contrôles réseau)
- Story Designer nodal (Cytoscape.js, import/export YAML, édition guidée, auto-layout)
- UI harmonisée (glass modern, labels FR, accessibilité)
---
## 🚀 Installation & démarrage rapide
```bash
npm install
npm run dev
```
Accès local : http://localhost:5173
Accès LAN : http://<ip-machine>:5173
Preset ESP :
```bash
npm run dev:esp
```
---
## 🛠️ Usage
Détection automatique du firmware connecté :
- `story_v2` : endpoints `/api/story/*` + WebSocket
- `freenove_legacy` : endpoints `/api/status`, `/api/scenario/*`, `/api/stream` (SSE)
Diagnostics firmware :
- Version, OTA, reboot détectés automatiquement
- État affiché dans le panneau "Firmware"
Variables d'environnement :
- `VITE_API_BASE` (ex: `http://192.168.0.91`)
- `VITE_API_PROBE_PORTS` (défaut: `80,8080`)
- `VITE_API_FLAVOR` (`auto|story_v2|freenove_legacy`, défaut `auto`)
- `VITE_API_ACCESS_MODE` (`hybrid|direct|proxy`, défaut `hybrid`)
- `VITE_ZACUS_STUDIO_AI_URL` (ex: `http://127.0.0.1:8787/story_generate`) pour activer la génération IA locale des scénarios et du manifeste imprimables.
Intégration IA locale (Docker) :
```bash
cd tools/dev/docker-studio-ai
cp .env.example .env
docker compose up -d --build
docker exec -it zacus-ollama ollama pull qwen2.5-coder:14b
```
Puis lancer le frontend avec `fronted dev web UI/.env.local` :
```bash
VITE_ZACUS_STUDIO_AI_URL=http://127.0.0.1:8787/story_generate
```
---
## 🤝 Contribuer
Les contributions sont bienvenues !
Merci de lire [../../CONTRIBUTING.md](../../CONTRIBUTING.md) avant toute PR.
---
## 🧑‍🎓 Licence
- **Code** : MIT (`../../LICENSE`)
---
## 👤 Contact
Pour toute question ou suggestion, ouvre une issue GitHub ou contacte lauteur principal :
- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare)
- Preview local: `http://localhost:4173`
- Preview LAN: `http://<ip-machine>:4173`
Build + preview:
```bash
npm run preview:esp:build
```
## Tests / gates frontend
```bash
npm run lint
npm run build
npm run test:unit
npm run test:e2e
npm run test:e2e:live
```
- `test:unit`: validation parser/generation YAML Story Designer.
- `playwright @mock`: detection Story V2/legacy, UX et regressions principales.
- `playwright @live`: tests live sur `192.168.0.91` (actions mutantes autorisees).
## Notes live
- La suite `@live` execute des actions de controle (`unlock`, `next`, `wifi reconnect`, `espnow off/on`).
- Un finalizer force `espnow on` en fin de run.
- A lancer sur une fenetre de test dediee (pas pendant une partie active).
-19
View File
@@ -1,19 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', '.svelte-kit', 'build', 'coverage', 'playwright-report', 'test-results']),
{
files: ['**/*.{ts,js}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: {
...globals.browser,
...globals.node,
},
},
},
])
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
{
"name": "fronted-dev-web-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:esp": "VITE_API_BASE=http://192.168.0.91 VITE_API_PROBE_PORTS=80,8080 VITE_API_FLAVOR=auto VITE_API_ACCESS_MODE=hybrid vite dev --host --port 5173",
"build": "svelte-kit sync && vite build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint . --ext .ts,.js",
"preview": "vite preview",
"preview:esp": "VITE_API_BASE=http://192.168.0.91 VITE_API_PROBE_PORTS=80,8080 VITE_API_FLAVOR=auto VITE_API_ACCESS_MODE=hybrid vite preview --host --port 4173",
"preview:esp:build": "npm run build && npm run preview:esp",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:live": "playwright test --grep @live"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.10.2",
"cytoscape": "^3.30.2",
"cytoscape-dagre": "^2.5.0",
"cytoscape-edgehandles": "^4.0.1",
"dagre": "^0.8.5",
"svelte": "^5.0.0",
"yaml": "^2.8.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/dagre": "^0.7.53",
"@types/node": "^24.10.1",
"eslint": "^9.39.1",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"svelte-check": "^4.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}
-33
View File
@@ -1,33 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
const isCi = !!process.env.CI
const hasCliGrep = process.argv.some((arg) => arg === '--grep' || arg.startsWith('--grep='))
export default defineConfig({
testDir: './tests/e2e',
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: isCi,
retries: isCi ? 1 : 0,
reporter: 'list',
grep: hasCliGrep ? undefined : /@mock/,
use: {
baseURL: 'http://127.0.0.1:4173',
headless: true,
trace: 'retain-on-failure',
},
webServer: {
command:
'VITE_API_BASE=http://127.0.0.1:4173 VITE_API_PROBE_PORTS=4173 VITE_API_FLAVOR=auto npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !isCi,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
@@ -1,181 +0,0 @@
# Spécification frontend — Story Designer (Svelte/Cytoscape, style Scratch-like)
## 1) Objectif
Spécifier le frontend web en alignement strict avec limplémentation réelle :
- édition visuelle Story V2,
- pilotage runtime (déploiement, validation, test),
- pages support (dashboard, média, réseau, diagnostics).
Le runtime reste la source de vérité côté exécution.
`hardware/firmware` est hors-scope frontend.
## 2) Stack réelle (au lieu dune stack cible théorique)
Le projet web actuel est basé sur **SvelteKit + Svelte stores + Cytoscape**, et non React/XYFlow.
Références implémentées :
- `fronted dev web UI/src/routes/+layout.svelte`
- `fronted dev web UI/src/routes/designer/+page.svelte`
- `fronted dev web UI/src/lib/stores/designer.store.ts`
- `fronted dev web UI/src/lib/stores/runtime.store.ts`
- `fronted dev web UI/src/lib/stores/scenario.store.ts`
- `fronted dev web UI/src/lib/stores/media.store.ts`
- `fronted dev web UI/src/lib/runtimeService.ts`
- `fronted dev web UI/src/lib/deviceApi.ts`
- `fronted dev web UI/src/features/story-designer/{types,storyYaml,validation}.ts`
## 3) Décision UX : rester en style Scratch-like (skin), pas en Blockly
**Décision actuelle:**
- On continue avec **Cytoscape comme moteur**, et un rendu visuel **Scratch-like par skin/UX** (palette de type bloc, badges de statut, panneaux propriétés dédiés, lien visuel orienté nœuds).
- Pas de bascule vers Blockly ou `@xyflow/react` tant que la conversion YAML↔graphe est stable et que la chaîne de build/test fonctionne.
**Raison pragmatique:**
- La base technique existante est déjà opérationnelle et valide (`/api/story/validate`, `/api/story/deploy`, auto-layout, undo/redo, import/export).
- Refaire le moteur (Blockly/XYFlow) ferait sauter la logique métier actuelle : persistance, historique, et contrat `StoryGraphDocument`.
- La feuille de route “nœuds améliorés” est compatible avec le stack Cytoscape et permet d’évoluer visuellement sans refondre le moteur.
**Plan recommandé (phase suivante):**
1. améliorer les nœuds existants (catégories visuelles + validité inline + mini-guide),
2. seulement ensuite évaluer un vrai Blockly si besoin produit par produit.
## 4) Portée synchronisée
- **Designer visuel** : édition dun graphe, import YAML → graphe, export graphe → YAML.
- **Contrôle runtime** : validate, deploy, test-run, start/pause/resume/skip/unlock selon capacité.
- **Gestion média + réseau + diagnostics** déjà intégrés dans lapp SvelteKit.
- Définition du projet cohérente avec :
- `scenario-ai-coherence/zacus_conversation_bundle_v3/fsm_mermaid 2.md`
- `specs/STORY_RUNTIME_API_JSON_CONTRACT.md`
- `specs/MEDIA_MANAGER_RUNTIME_SPEC.md`
- `fronted dev web UI/specs/MEDIA_MANAGER_FRONTEND_SPEC.md`
## 5) UX réelle du studio Designer
Le routeur expose les onglets :
- Dashboard
- Scénario
- Designer
- Media Manager
- Réseau
- Diagnostics
Sur la page `Designer`, le comportement réel attendu est :
- **Canvas Cytoscape** avec :
- création/suppression de node,
- déplacement libre (drag),
- liaison mode à deux clics (`Mode liaison` puis clic destination),
- auto-layout (`dagre`),
- styles visuels (initial, sélection, link mode).
- **Inspector latéral** :
- édition node (`step_id`, `screen_scene_id`, `audio_pack_id`, initial),
- édition edge (`event_type`, `event_name`, `priority`),
- suppression edge/node.
- **Validation locale** en continu (warnings/erreurs).
- **Actions globales** :
- Undo/Redo (stack max 80),
- import/export YAML,
- validate/deploy/test-run via runtime quand disponibles,
- statut de chargement/erreur moteur.
- **Persist local** :
- draft YAML `studio:v3:draft`,
- graphe JSON `studio:v3:graph` (localStorage).
- **Keyboard** :
- `Ctrl/Cmd+Z`, `Ctrl/Cmd+Y`.
## 6) Modèle de données éditeur (source de vérité front)
`StoryGraphDocument`:
- `scenarioId: string`
- `version: number`
- `initialStep: string`
- `appBindings[]`
- `nodes[]` :
- `id`, `stepId`, `screenSceneId`, `audioPackId`, `actions`, `apps`, `mp3GateOpen`, `x`, `y`, `isInitial`
- `edges[]` :
- `id`, `fromNodeId`, `toNodeId`, `trigger`, `eventType`, `eventName`, `afterMs`, `priority`
## 7) Conversion YAML ↔ graph (tolérante)
Le parser actuel supporte :
- `initial_step` ou `initial_step_id` (fallback sur premier step si invalide),
- `target_step_id` ou `target`,
- normalisation tokens par `normalizeToken` (majuscules + `_` + remplacement caractères non standards),
- `app_bindings` absent → bindings par défaut,
- import de `scenario` mal nommé → avertissements non bloquants,
- suppression des transitions avec target inexistant (warning + continue).
La génération YAML :
- produit `id/version/initial_step/app_bindings/steps`,
- normalise les IDs,
- ordonne les nodes pour stabilité,
- `event_type` + `event_name` générés depuis `edge`.
## 8) Validation
Validation locale (`StoryGraphDocument`) :
- `scenarioId` non vide
- `version` valide
- au moins 1 node, exactement 1 initial
- `stepId` unique
- edges avec source/cible existantes
- `trigger`/`event_type` valides, `event_name` non vide, priorités bornées.
Validation API :
- POST `/api/story/validate` via `scenarioStore.validateYaml`.
- Exécution bloquée en mode legacy (garde-fou via capabilities runtime).
Déploiement/test :
- POST `/api/story/deploy` (`scenarioStore.deployYaml`)
- `test-run` = deploy → select → start (uniquement si story_v2).
## 9) Intégration runtime et orchestrations
Runtime store (`runtime.store.ts`) :
- bootstrap au chargement,
- polling toutes les ~3s,
- stream quand disponible (WS/SSE selon découverte),
- refresh global via `/api/status`, `/api/story/status`, `/api/story/list`, `/api/media/record/status`.
Pilotage scénario :
- select/start/pause/resume/skip via `scenario.store.ts`,
- capacité runtime vérifiée (`canSelectScenario`, `canStart`, etc. de `deviceApi`).
Média :
- `/api/media/files?kind=music|picture|recorder`
- `/api/media/play`, `/api/media/stop`
- `/api/media/record/start`, `/api/media/record/stop`
- fallback compatible via `/api/control` en cas d’échec dédié.
Détection Media Manager :
- priorité `SCENE_MEDIA_MANAGER`,
- fallback `STEP_MEDIA_MANAGER`,
- fallback legacy si token `MEDIA_MANAGER` présent ailleurs.
## 10) Mapping FSM runtime cible (DEFAULT)
Le graphe/story édité doit couvrir le contrat du runtime source `fsm_mermaid 2.md` :
- `STEP_U_SON_PROTO` état initial,
- boucles, transitions, timeouts et actions/serial identiques en labels d’événements,
- conservation stricte des noms événementiels (`BTN`, `AUDIO_DONE`, `ACK_*`, `ETAPE*`, `FORCE_*`, `WIN_DUE`, etc.),
- tolérance des IDs mixtes (`STEP_*` + `SCENE_*`) sans blocage.
## 11) Conditions dacceptation front
- 1) Designer chargé sans erreur de moteur Cytoscape.
- 2) Import YAML → export YAML en roundtrip sans perte fonctionnelle majeure.
- 3) Erreurs de validation locale visibles immédiatement (warnings/errors persistants dans le panneau statut).
- 4) Undo/Redo fonctionne avec historique cohérent (capacité 80).
- 5) Validate/deploy/test-run déclenchés uniquement si support API Story V2.
- 6) Auto-layout non destructif + persist local opérationnelle.
- 7) Détection Media Manager conforme (scene > step > legacy).
## 12) Décision de livraison finale (frontend)
- **On valide un “scratch-like visuel” basé sur `Cytoscape` + `nœuds améliorés`, pas une migration vers Blockly.**
- Les futurs gains UX passent par :
- amélioration de la peau visuelle des nœuds,
- éditoriaux contextuels plus guidants,
- validations inline plus fines,
- sans changer le moteur ou le contrat `StoryGraphDocument`.
-12
View File
@@ -1,12 +0,0 @@
import adapter from '@sveltejs/adapter-node'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
}
export default config
-17
View File
@@ -1,17 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.d.ts", "src/**/*.svelte", "tests/**/*.ts"],
"exclude": ["node_modules"]
}
-12
View File
@@ -1,12 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
passWithNoTests: false,
},
})
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
passWithNoTests: false,
},
})
+57 -43
View File
@@ -1,29 +1,9 @@
# Zacus Scratch Frontend V2 (greenfield) # Zacus Story Designer — React 19 + Blockly
Frontend "from scratch" pour designer un scenario Zacus avec des blocs type Scratch, Studio auteur visuel pour concevoir des scenarios Zacus avec des blocs type Scratch.
generer du YAML, puis appeler les endpoints Story V2. Genere du YAML canonique et communique avec le firmware ESP32 via l'API Story V2.
## Objectif ## Setup
- Ne pas reutiliser le frontend legacy du repo.
- Construire un socle neuf, orientee OSS/libre.
- Garder la compatibilite API avec `STORY_V2_WEBUI.md`.
## Stack OSS retenue
Validation rapide faite le 2026-03-01 (npm registry):
| Brique | Version | Licence | Role |
| --- | --- | --- | --- |
| blockly | 12.4.1 | Apache-2.0 | editeur blocs |
| yaml | 2.8.2 | ISC | serialisation YAML |
| zod | 4.3.6 | MIT | validation locale de schema |
| ajv | 8.18.0 | MIT | JSON schema (next step) |
| @monaco-editor/react | 4.7.0 | MIT | vue YAML |
Note: `scratch-gui` / `scratch-vm` sont AGPL-3.0-only, donc non retenus ici pour garder un front permissif.
## Demarrage
```bash ```bash
cd frontend-scratch-v2 cd frontend-scratch-v2
@@ -31,32 +11,66 @@ npm install
npm run dev npm run dev
``` ```
Option API: Connexion API (optionnel) :
```bash ```bash
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
``` ```
## Fonctionnalites livrees ## Scripts disponibles
- Workspace Blockly avec bloc `step`. | Commande | Description |
- Generation YAML live depuis les blocs. | --- | --- |
- Preview/edit YAML dans Monaco. | `npm run dev` | Serveur de dev Vite (HMR) |
- Actions runtime: | `npm run build` | Build production (`dist/`) |
- GET `/api/story/list` | `npm test` | Tests Vitest (18 tests) |
- GET `/api/story/status` | `npm run lint` | ESLint |
- POST `/api/story/validate`
- POST `/api/story/deploy`
## Structure ## Architecture
- `src/components/BlocklyDesigner.tsx`: editeur blocs + generation YAML. L'interface s'organise en 4 onglets :
- `src/components/RuntimeControls.tsx`: actions HTTP Story V2.
- `src/lib/scenario.ts`: mapping blocs -> document scenario -> YAML.
- `src/types.ts`: types du document scenario.
## Prochain lot recommande | Onglet | Composant | Role |
| --- | --- | --- |
| Designer | `BlocklyDesigner.tsx` | Editeur blocs + generation YAML live |
| Dashboard | `Dashboard.tsx` | Vue d'ensemble scenario |
| Media | `MediaManager.tsx` | Gestion assets audio/image |
| Network | `NetworkPanel.tsx` | Monitoring devices terrain |
1. Ajouter un mapping complet `steps + transitions` (FSM) avec validation croisee. Autres fichiers cles :
2. Ajouter import/export Blockly JSON. - `src/components/RuntimeControls.tsx` — actions HTTP Story V2 (list, status, validate, deploy)
3. Ajouter tests unitaires de generation YAML et smoke E2E API mock. - `src/lib/scenario.ts` — mapping blocs → document scenario → YAML
- `src/types.ts` — types TypeScript du document scenario
## Stack OSS
| Brique | Version | Licence | Role |
| --- | --- | --- | --- |
| blockly | 12.4.1 | Apache-2.0 | editeur blocs |
| yaml | 2.8.2 | ISC | serialisation YAML |
| zod | 4.3.6 | MIT | validation locale |
| ajv | 8.18.0 | MIT | JSON schema |
| @monaco-editor/react | 4.7.0 | MIT | vue YAML |
Note : `scratch-gui` / `scratch-vm` sont AGPL-3.0, non retenus pour garder un front permissif.
## API Story V2
| Methode | Endpoint | Description |
| --- | --- | --- |
| GET | `/api/story/list` | Liste des scenarios |
| GET | `/api/story/status` | Statut runtime |
| POST | `/api/story/validate` | Validation YAML |
| POST | `/api/story/deploy` | Deploiement sur device |
Variable d'environnement : `VITE_STORY_API_BASE` (defaut : pas de proxy).
## Tests
18 tests passing (Vitest). Lancer avec `npm test`.
## Prochaines etapes
1. Mapping complet steps + transitions (FSM) avec validation croisee
2. Import/export Blockly JSON
3. Tests E2E avec API mock
+451 -1
View File
@@ -28,7 +28,8 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^3.2.4"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -1592,6 +1593,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1960,6 +1979,121 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2031,6 +2165,16 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2108,6 +2252,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2139,6 +2293,23 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2156,6 +2327,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2261,6 +2442,16 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2297,6 +2488,13 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -2560,6 +2758,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2570,6 +2778,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3028,6 +3246,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3038,6 +3263,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": { "node_modules/marked": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
@@ -3215,6 +3450,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3441,6 +3693,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3451,12 +3710,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/state-local": { "node_modules/state-local": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3470,6 +3743,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3489,6 +3782,20 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3506,6 +3813,36 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tldts": { "node_modules/tldts": {
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
@@ -3735,6 +4072,102 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -3807,6 +4240,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+3 -1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -30,6 +31,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^3.2.4"
} }
} }
+412 -69
View File
@@ -1,110 +1,453 @@
/* ─── Reset & Base ─── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--bg-card: #1a1d27;
--bg-input: #252833;
--border: #2e3142;
--text: #e4e6eb;
--text-muted: #8b8fa3;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #22c55e;
--red: #ef4444;
--orange: #f59e0b;
--radius: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* ─── Shell ─── */
.app-shell { .app-shell {
min-height: 100vh;
padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; min-height: 100vh;
}
/* ─── Header ─── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
gap: 16px;
flex-wrap: wrap;
} }
.app-header h1 { .app-header h1 {
margin: 0; font-size: 18px;
font-size: 1.6rem; font-weight: 700;
white-space: nowrap;
} }
.app-header p { .header-status {
margin: 0.25rem 0 0; display: flex;
opacity: 0.82; align-items: center;
gap: 10px;
} }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.connected {
background: var(--green);
box-shadow: 0 0 6px var(--green);
}
.dot.disconnected {
background: var(--red);
}
.api-url-input {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 4px 10px;
font-size: 13px;
font-family: var(--mono);
width: 240px;
}
.header-step {
color: var(--accent);
font-size: 13px;
}
/* ─── Tabs ─── */
.tab-bar {
display: flex;
gap: 0;
background: var(--bg-card);
border-bottom: 2px solid var(--border);
padding: 0 20px;
}
.tab {
padding: 10px 20px;
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
border-radius: 0;
}
.tab:hover {
color: var(--text);
background: none;
border-color: transparent;
border-bottom-color: var(--text-muted);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ─── Main ─── */
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* ─── Cards ─── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
.card h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.panel-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.editor-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* ─── KV pairs ─── */
.kv {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.kv:last-child {
border-bottom: none;
}
.runtime3-grid {
display: grid;
gap: 8px 16px;
}
.runtime3-span {
grid-column: 1 / -1;
}
.runtime3-path {
word-break: break-all;
text-align: right;
}
/* ─── Badges ─── */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background: var(--bg-input);
color: var(--text-muted);
}
.badge.ok {
background: rgba(34, 197, 94, 0.15);
color: var(--green);
}
.badge.err {
background: rgba(239, 68, 68, 0.15);
color: var(--red);
}
.badge.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
}
/* ─── Buttons ─── */
button {
padding: 6px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
button:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
/* ─── Inputs ─── */
input[type='text'],
input[type='number'] {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
/* ─── Notices ─── */
.notice {
padding: 8px 12px;
border-radius: var(--radius);
font-size: 13px;
margin: 8px 0;
}
.notice.warn {
background: rgba(245, 158, 11, 0.12);
color: var(--orange);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.notice.info {
background: rgba(59, 130, 246, 0.12);
color: var(--accent);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.notice.err {
background: rgba(239, 68, 68, 0.12);
color: var(--red);
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* ─── Progress ─── */
.progress-bar {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.progress-bar .progress-fill {
height: 6px;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s;
flex: 1;
max-width: 200px;
}
.progress-bar span {
font-size: 12px;
font-family: var(--mono);
}
/* ─── Lists ─── */
.scenario-list,
.file-list {
list-style: none;
}
.scenario-list li,
.file-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.scenario-list li:last-child,
.file-list li:last-child {
border-bottom: none;
}
/* ─── Misc ─── */
.mono {
font-family: var(--mono);
font-size: 13px;
}
.muted {
color: var(--text-muted);
font-size: 13px;
}
.feedback {
margin-top: 8px;
padding: 6px 10px;
border-radius: var(--radius);
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
font-size: 13px;
}
.error-banner {
background: rgba(239, 68, 68, 0.12);
color: var(--red);
padding: 8px 12px;
border-radius: var(--radius);
margin-top: 12px;
}
.event-json {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
background: var(--bg);
padding: 8px;
border-radius: var(--radius);
overflow-x: auto;
max-height: 200px;
}
/* ─── Designer grid ─── */
.app-grid { .app-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 16px;
}
@media (max-width: 900px) {
.app-grid {
grid-template-columns: 1fr;
}
} }
.panel { .panel {
border: 1px solid #d0d7de; background: var(--bg-card);
border-radius: 0.75rem; border: 1px solid var(--border);
padding: 0.75rem; border-radius: var(--radius);
background: #ffffff; padding: 16px;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 300px;
} }
.panel h2 { .panel h2 {
margin: 0; font-size: 15px;
font-size: 1.05rem; font-weight: 600;
margin-bottom: 12px;
} }
/* ─── Blockly ─── */
.blockly-host { .blockly-host {
height: 48vh; height: 50vh;
min-height: 340px; min-height: 340px;
border: 1px solid #d0d7de; border: 1px solid var(--border);
border-radius: 0.5rem; border-radius: var(--radius);
overflow: hidden; overflow: hidden;
} }
.toolbar { .toolbar {
display: flex; display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem;
}
.toolbar input {
min-width: 220px;
border: 1px solid #d0d7de;
border-radius: 0.4rem;
padding: 0.4rem 0.6rem;
}
.toolbar button {
border: 1px solid #d0d7de;
border-radius: 0.4rem;
background: #f6f8fa;
padding: 0.4rem 0.7rem;
} }
.status-line { .status-line {
font-size: 0.85rem; margin-top: 8px;
opacity: 0.9; padding: 6px 10px;
border-radius: var(--radius);
background: rgba(34, 197, 94, 0.1);
color: var(--green);
font-size: 13px;
} }
.status-line.error { .status-line.error {
color: #b42318; background: rgba(239, 68, 68, 0.1);
color: var(--red);
} }
/* ─── Runtime panel ─── */
.runtime-panel { .runtime-panel {
display: grid; margin-top: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
} }
.runtime-panel .actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.runtime-panel pre { .runtime-panel pre {
margin: 0; font-family: var(--mono);
min-height: 120px; font-size: 12px;
border: 1px solid #d0d7de; color: var(--text-muted);
border-radius: 0.5rem; background: var(--bg);
background: #f6f8fa; padding: 8px;
padding: 0.6rem; border-radius: var(--radius);
overflow: auto; overflow-x: auto;
font-size: 0.8rem; max-height: 200px;
margin-top: 8px;
white-space: pre-wrap;
} }
@media (max-width: 980px) { /* ─── Rec form ─── */
.app-grid { .rec-form {
grid-template-columns: 1fr; display: flex;
} gap: 12px;
margin-bottom: 8px;
.runtime-panel { }
grid-template-columns: 1fr; .rec-form input {
} width: 120px;
}
/* ─── Dashboard layout ─── */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.dashboard h2 {
grid-column: 1 / -1;
font-size: 18px;
font-weight: 700;
}
.media-manager,
.network-panel {
max-width: 720px;
} }
+186 -25
View File
@@ -1,40 +1,201 @@
import { useState } from 'react'; import { Component, Suspense, lazy, useState } from 'react';
import Editor from '@monaco-editor/react'; import type { ErrorInfo, ReactNode } from 'react';
import { BlocklyDesigner } from './components/BlocklyDesigner';
import { RuntimeControls } from './components/RuntimeControls'; import { RuntimeControls } from './components/RuntimeControls';
import { Dashboard } from './components/Dashboard';
import { useRuntimeStore } from './lib/useRuntimeStore';
import { setApiBase, getApiBase } from './lib/api';
import './App.css'; import './App.css';
// ─── Lazy-loaded heavy components ───
const LazyBlocklyDesigner = lazy(() =>
import('./components/BlocklyDesigner').then((m) => ({ default: m.BlocklyDesigner })),
);
const LazyMonacoEditor = lazy(() => import('@monaco-editor/react'));
const LazyMediaManager = lazy(() =>
import('./components/MediaManager').then((m) => ({ default: m.MediaManager })),
);
const LazyNetworkPanel = lazy(() =>
import('./components/NetworkPanel').then((m) => ({ default: m.NetworkPanel })),
);
const LazyFallback = (
<div style={{ padding: '2rem', textAlign: 'center' }}>Loading editor...</div>
);
// ─── ErrorBoundary ───
interface ErrorBoundaryProps {
children: ReactNode;
fallbackLabel?: string;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(`[ErrorBoundary${this.props.fallbackLabel ? ` ${this.props.fallbackLabel}` : ''}]`, error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary-fallback" role="alert">
<h2>Something went wrong{this.props.fallbackLabel ? ` in ${this.props.fallbackLabel}` : ''}.</h2>
<pre>{this.state.error?.message}</pre>
<button type="button" onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
export { ErrorBoundary };
type Tab = 'dashboard' | 'designer' | 'media' | 'network';
const TABS: { id: Tab; label: string }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'designer', label: 'Designer' },
{ id: 'media', label: 'Media' },
{ id: 'network', label: 'Network' },
];
function App() { function App() {
const [tab, setTab] = useState<Tab>('dashboard');
const [yaml, setYaml] = useState(''); const [yaml, setYaml] = useState('');
const [runtime3Json, setRuntime3Json] = useState('');
const [apiUrl, setApiUrl] = useState(getApiBase());
const runtime = useRuntimeStore();
const handleApiChange = (url: string) => {
setApiUrl(url);
setApiBase(url);
};
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<h1>Zacus Scratch Frontend V2</h1> <h1>Zacus Studio V2</h1>
<p>Nouvelle base from scratch, orientee Blockly + YAML + API Story V2.</p> <div className="header-status">
<span
className={`dot ${runtime.connected ? 'connected' : 'disconnected'}`}
/>
<input
type="text"
className="api-url-input"
value={apiUrl}
onChange={(e) => handleApiChange(e.target.value.trim())}
aria-label="ESP32 API URL"
placeholder="http://esp32-ip:8080"
/>
{runtime.story && (
<span className="header-step mono">
{runtime.story.current_step}
</span>
)}
</div>
</header> </header>
<main className="app-grid"> <nav className="tab-bar">
<section className="panel"> {TABS.map((t) => (
<h2>Designer blocs</h2> <button
<BlocklyDesigner onYamlChange={setYaml} /> key={t.id}
</section> type="button"
className={`tab ${tab === t.id ? 'active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</nav>
<section className="panel"> <main className="main-content">
<h2>YAML runtime</h2> {tab === 'dashboard' && (
<Editor <ErrorBoundary fallbackLabel="Dashboard">
height="40vh" <Dashboard runtime={runtime} refresh={runtime.refresh} />
defaultLanguage="yaml" </ErrorBoundary>
value={yaml} )}
onChange={(value) => setYaml(value ?? '')}
options={{ {tab === 'designer' && (
minimap: { enabled: false }, <ErrorBoundary fallbackLabel="Designer">
scrollBeyondLastLine: false, <Suspense fallback={LazyFallback}>
fontSize: 13, <div className="app-grid">
}} <section className="panel">
/> <h2>Designer blocs</h2>
<RuntimeControls yaml={yaml} /> <LazyBlocklyDesigner
</section> onDraftChange={(draft) => {
setYaml(draft.yaml);
setRuntime3Json(draft.runtime3Json);
}}
/>
</section>
<section className="panel panel-stack">
<div className="editor-group">
<h2>YAML canonique</h2>
<LazyMonacoEditor
height="28vh"
defaultLanguage="yaml"
value={yaml}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
readOnly: true,
}}
/>
</div>
<div className="editor-group">
<h2>IR Runtime 3</h2>
<LazyMonacoEditor
height="28vh"
defaultLanguage="json"
value={runtime3Json}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
readOnly: true,
}}
/>
</div>
<RuntimeControls yaml={yaml} />
</section>
</div>
</Suspense>
</ErrorBoundary>
)}
{tab === 'media' && (
<ErrorBoundary fallbackLabel="Media">
<Suspense fallback={LazyFallback}>
<LazyMediaManager runtime={runtime} />
</Suspense>
</ErrorBoundary>
)}
{tab === 'network' && (
<ErrorBoundary fallbackLabel="Network">
<Suspense fallback={LazyFallback}>
<LazyNetworkPanel runtime={runtime} />
</Suspense>
</ErrorBoundary>
)}
</main> </main>
</div> </div>
); );
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// We need to test the internal jsonFetch + the exported helpers.
// Since jsonFetch is not exported, we test it indirectly through storyList / storyStatus
// and also test setApiBase / getApiBase directly.
import {
setApiBase,
getApiBase,
storyList,
storyStatus,
ApiError,
} from '../lib/api';
// ─── Mock globalThis.fetch ───
const mockFetch = vi.fn<(...args: unknown[]) => Promise<Response>>();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
setApiBase('http://test-esp:8080');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ─── Helpers ───
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
// ─── Tests ───
describe('setApiBase / getApiBase', () => {
it('stores and returns the base URL', () => {
setApiBase('http://192.168.0.42:8080');
expect(getApiBase()).toBe('http://192.168.0.42:8080');
});
it('strips trailing slashes', () => {
setApiBase('http://host:9000///');
expect(getApiBase()).toBe('http://host:9000');
});
});
describe('jsonFetch (via storyList)', () => {
it('returns parsed JSON on 200', async () => {
const payload = { scenarios: [{ id: 'demo', version: 1, estimated_duration_s: 300 }] };
mockFetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await storyList();
expect(result).toEqual(payload.scenarios);
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toBe('http://test-esp:8080/api/story/list');
});
it('throws ApiError on HTTP 500', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: 'internal failure' }, 500),
);
try {
await storyList();
expect.unreachable('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(500);
expect((err as ApiError).message).toBe('internal failure');
}
});
it('throws on timeout (AbortError path)', async () => {
// Simulate a request that never resolves — the AbortController will fire
mockFetch.mockImplementation(
(...args: unknown[]) =>
new Promise((_resolve, reject) => {
const init = args[1] as { signal?: AbortSignal } | undefined;
if (init?.signal) {
init.signal.addEventListener('abort', () => {
const err = new DOMException('The operation was aborted.', 'AbortError');
reject(err);
});
}
}),
);
// storyStatus uses the default 5000ms timeout — too long for tests.
// Instead we test indirectly: the AbortController fires, the promise rejects.
// We reduce the wait by calling storyStatus which has default timeout.
// For speed we rely on the abort signal being set up correctly.
const promise = storyStatus();
await expect(promise).rejects.toThrow(/timed out/);
}, 10_000);
});
describe('storyList', () => {
it('calls /api/story/list and unwraps scenarios', async () => {
const scenarios = [
{ id: 'a', version: 2, estimated_duration_s: 600 },
{ id: 'b', version: 1, estimated_duration_s: 120 },
];
mockFetch.mockResolvedValueOnce(jsonResponse({ scenarios }));
const result = await storyList();
expect(result).toHaveLength(2);
expect(result[0].id).toBe('a');
});
});
describe('storyStatus', () => {
it('calls /api/story/status and returns status object', async () => {
const status = {
status: 'running',
scenario_id: 'demo',
current_step: 'STEP_1',
progress_pct: 42,
started_at_ms: 1700000000000,
selected: 'demo',
queue_depth: 0,
};
mockFetch.mockResolvedValueOnce(jsonResponse(status));
const result = await storyStatus();
expect(result.status).toBe('running');
expect(result.current_step).toBe('STEP_1');
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toBe('http://test-esp:8080/api/story/status');
});
});
@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import {
compileScenarioDocumentToRuntime3,
validateRuntime3Document,
} from '../lib/runtime3';
import type { ScenarioDocument } from '../types';
// ─── Helpers ───
function makeDocument(overrides?: Partial<ScenarioDocument>): ScenarioDocument {
return {
id: 'test-scenario',
version: 1,
title: 'Test Scenario',
players: { min: 2, max: 6 },
duration: { total_minutes: 45 },
canon: { introduction: 'Intro', stakes: 'Stakes' },
stations: [],
puzzles: [],
steps_narrative: [{ step_id: 'intro', scene: 'scene1', narrative: 'Start' }],
firmware: {
initial_step: 'intro',
steps: [
{
step_id: 'intro',
screen_scene_id: 'scene_welcome',
audio_pack_id: 'pack_intro',
actions: ['play_music'],
apps: [],
transitions: [
{
event_type: 'button',
event_name: 'btn_next',
target_step_id: 'puzzle_1',
priority: 10,
after_ms: 0,
},
{
event_type: 'timer',
event_name: 'timeout',
target_step_id: 'puzzle_1',
priority: 5,
after_ms: 30000,
},
],
},
{
step_id: 'puzzle_1',
screen_scene_id: 'scene_puzzle',
audio_pack_id: 'pack_puzzle',
actions: [],
apps: ['puzzle_app'],
transitions: [
{
event_type: 'serial',
event_name: 'solved',
target_step_id: 'intro',
priority: 0,
after_ms: 0,
},
],
},
],
},
...overrides,
};
}
// ─── Tests ───
describe('compileScenarioDocumentToRuntime3', () => {
it('produces correct schema_version', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
expect(result.schema_version).toBe('zacus.runtime3.v1');
});
it('sets metadata correctly', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
expect(result.metadata.generated_by).toBe('frontend-scratch-v2');
expect(result.metadata.migration_mode).toBe('native');
});
it('normalizes step IDs to uppercase', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
for (const step of result.steps) {
expect(step.id).toBe(step.id.toUpperCase());
}
expect(result.steps[0].id).toBe('INTRO');
expect(result.steps[1].id).toBe('PUZZLE_1');
});
it('normalizes transition target_step_id to uppercase', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
for (const step of result.steps) {
for (const t of step.transitions) {
expect(t.target_step_id).toBe(t.target_step_id.toUpperCase());
}
}
});
it('sorts transitions are preserved with priority values', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
const introTransitions = result.steps[0].transitions;
expect(introTransitions).toHaveLength(2);
// Priorities are preserved as-is from the source
expect(introTransitions[0].priority).toBe(10);
expect(introTransitions[1].priority).toBe(5);
});
it('sets entry_step_id correctly from firmware.initial_step', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
expect(result.scenario.entry_step_id).toBe('INTRO');
});
it('falls back to first firmware step if initial_step is missing', () => {
const doc = makeDocument();
delete doc.firmware!.initial_step;
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.scenario.entry_step_id).toBe('INTRO');
});
it('falls back to STEP_BOOT when no steps exist', () => {
const doc = makeDocument({
firmware: { steps: [] },
steps_narrative: [],
});
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.scenario.entry_step_id).toBe('STEP_BOOT');
});
it('normalizes scenario id', () => {
const doc = makeDocument({ id: 'my cool scenario!' });
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.scenario.id).toBe('MY_COOL_SCENARIO');
});
it('handles unknown event_type by falling back to serial', () => {
const doc = makeDocument();
doc.firmware!.steps![0].transitions[0].event_type = 'unknown_event';
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.steps[0].transitions[0].event_type).toBe('serial');
});
});
describe('validateRuntime3Document', () => {
it('validates a correct document', () => {
const doc = compileScenarioDocumentToRuntime3(makeDocument());
const result = validateRuntime3Document(doc);
expect(result).toEqual({ ok: true });
});
it('rejects empty steps', () => {
const doc = compileScenarioDocumentToRuntime3(
makeDocument({ firmware: { steps: [] }, steps_narrative: [] }),
);
doc.steps = [];
const result = validateRuntime3Document(doc);
expect(result).toEqual({ ok: false, error: 'runtime3 requires at least one step' });
});
it('rejects duplicate step IDs', () => {
const doc = compileScenarioDocumentToRuntime3(makeDocument());
doc.steps.push({ ...doc.steps[0] }); // duplicate
const result = validateRuntime3Document(doc);
expect(result.ok).toBe(false);
expect('error' in result && result.error).toContain('duplicate');
});
});
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest';
import {
buildScenarioFromBlocks,
validateScenarioDocument,
normalizeId,
} from '../lib/scenario';
import type { ScenarioStep, ScenarioDocument } from '../types';
// ─── Helpers ───
function makeValidSteps(overrides?: Partial<ScenarioStep>[]): ScenarioStep[] {
const defaults: ScenarioStep[] = [
{
stepId: 'STEP_A',
sceneId: 'SCENE_A',
transitions: [
{
eventType: 'button',
eventName: 'BTN_NEXT',
targetStepId: 'STEP_B',
priority: 0,
afterMs: 0,
},
],
},
{
stepId: 'STEP_B',
sceneId: 'SCENE_B',
transitions: [],
},
];
if (!overrides) return defaults;
return overrides.map((o, i) => ({ ...defaults[i % defaults.length], ...o }));
}
function makeValidDocument(
steps?: ScenarioStep[],
scenarioId = 'TEST_SCENARIO',
): ScenarioDocument {
return buildScenarioFromBlocks(scenarioId, steps ?? makeValidSteps());
}
// ─── Tests ───
describe('scenario validation', () => {
it('valid scenario passes validation', () => {
const doc = makeValidDocument();
const result = validateScenarioDocument(doc);
expect(result).toEqual({ ok: true });
});
it('missing required field (empty id) fails', () => {
const doc = makeValidDocument(makeValidSteps(), '');
// buildScenarioFromBlocks normalizes empty to fallback, so force it
doc.id = '';
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
});
it('missing title fails', () => {
const doc = makeValidDocument();
doc.title = '';
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
});
it('duplicate step IDs detected', () => {
const doc = makeValidDocument();
// Manually inject duplicate step IDs into firmware
if (doc.firmware?.steps) {
doc.firmware.steps.push({ ...doc.firmware.steps[0] });
doc.firmware.steps_reference_order = doc.firmware.steps.map(
(s) => s.step_id,
);
}
doc.steps_narrative.push({ ...doc.steps_narrative[0] });
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
expect('error' in result && result.error).toContain('duplicate');
});
it('transition target pointing to existing step passes', () => {
const steps: ScenarioStep[] = [
{
stepId: 'STEP_1',
sceneId: 'SCENE_1',
transitions: [
{
eventType: 'serial',
eventName: 'BTN_GO',
targetStepId: 'STEP_2',
priority: 0,
afterMs: 0,
},
],
},
{ stepId: 'STEP_2', sceneId: 'SCENE_2', transitions: [] },
];
const doc = makeValidDocument(steps);
expect(validateScenarioDocument(doc)).toEqual({ ok: true });
});
it('transition target pointing to non-existing step fails', () => {
const steps: ScenarioStep[] = [
{
stepId: 'STEP_1',
sceneId: 'SCENE_1',
transitions: [
{
eventType: 'serial',
eventName: 'BTN_GO',
targetStepId: 'STEP_GHOST',
priority: 0,
afterMs: 0,
},
],
},
];
const doc = makeValidDocument(steps);
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain('STEP_GHOST');
}
});
it('players.min > players.max fails', () => {
const doc = makeValidDocument();
doc.players = { min: 20, max: 5 };
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain('players');
}
});
it('empty steps array produces fallback STEP_BOOT', () => {
const doc = makeValidDocument([]);
expect(doc.firmware?.steps).toHaveLength(1);
expect(doc.firmware?.steps?.[0].step_id).toBe('STEP_BOOT');
expect(validateScenarioDocument(doc)).toEqual({ ok: true });
});
});
describe('normalizeId', () => {
it('empty string returns fallback', () => {
expect(normalizeId('', 'FALLBACK')).toBe('FALLBACK');
});
it('whitespace-only returns fallback', () => {
expect(normalizeId(' ', 'FB')).toBe('FB');
});
it('special characters are replaced with underscores', () => {
expect(normalizeId('hello-world!@#', 'X')).toBe('HELLO_WORLD');
});
it('unicode characters are stripped', () => {
const result = normalizeId('etape_cafe', 'X');
expect(result).toBe('ETAPE_CAFE');
});
it('leading/trailing underscores are removed', () => {
expect(normalizeId('__test__', 'X')).toBe('TEST');
});
it('already valid ID is uppercased', () => {
expect(normalizeId('step_one', 'X')).toBe('STEP_ONE');
});
});
@@ -5,7 +5,13 @@ import {
buildScenarioFromBlocks, buildScenarioFromBlocks,
scenarioToYaml, scenarioToYaml,
validateScenarioDocument, validateScenarioDocument,
parseYamlToSteps,
} from '../lib/scenario'; } from '../lib/scenario';
import {
compileScenarioDocumentToRuntime3,
runtime3ToJson,
validateRuntime3Document,
} from '../lib/runtime3';
import type { ScenarioStep } from '../types'; import type { ScenarioStep } from '../types';
const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = { const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
@@ -13,14 +19,15 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
contents: [ contents: [
{ {
kind: 'category', kind: 'category',
name: 'Scenario', name: 'Steps',
colour: '#3b82f6', colour: '#3b82f6',
contents: [ contents: [{ kind: 'block', type: 'zacus_step' }],
{ },
kind: 'block', {
type: 'zacus_step', kind: 'category',
}, name: 'Transitions',
], colour: '#22c55e',
contents: [{ kind: 'block', type: 'zacus_transition' }],
}, },
], ],
}; };
@@ -28,29 +35,50 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
let blocksRegistered = false; let blocksRegistered = false;
function ensureCustomBlocks(): void { function ensureCustomBlocks(): void {
if (blocksRegistered) { if (blocksRegistered) return;
return;
}
Blockly.defineBlocksWithJsonArray([ Blockly.defineBlocksWithJsonArray([
{ {
type: 'zacus_step', type: 'zacus_step',
message0: 'step %1 scene %2', message0: 'Step %1 Scene %2 Audio %3',
args0: [ args0: [
{ { type: 'field_input', name: 'STEP_ID', text: 'STEP_NEW' },
type: 'field_input', { type: 'field_input', name: 'SCENE_ID', text: 'SCENE_NEW' },
name: 'STEP_ID', { type: 'field_input', name: 'AUDIO_PACK', text: '' },
text: 'STEP_NEW',
},
{
type: 'field_input',
name: 'SCENE_ID',
text: 'SCENE_NEW',
},
], ],
message1: '%1',
args1: [{ type: 'input_statement', name: 'TRANSITIONS' }],
previousStatement: null, previousStatement: null,
nextStatement: null, nextStatement: null,
colour: 210, colour: 210,
tooltip: 'A scenario step with its screen scene and audio pack',
},
{
type: 'zacus_transition',
message0: 'on %1 : %2 → %3 after %4 ms prio %5',
args0: [
{
type: 'field_dropdown',
name: 'EVENT_TYPE',
options: [
['button', 'button'],
['serial', 'serial'],
['timer', 'timer'],
['audio_done', 'audio_done'],
['unlock', 'unlock'],
['espnow', 'espnow'],
['action', 'action'],
],
},
{ type: 'field_input', name: 'EVENT_NAME', text: 'BTN_NEXT' },
{ type: 'field_input', name: 'TARGET', text: 'STEP_NEXT' },
{ type: 'field_number', name: 'AFTER_MS', value: 0, min: 0, precision: 1 },
{ type: 'field_number', name: 'PRIORITY', value: 0, min: 0, precision: 1 },
],
previousStatement: null,
nextStatement: null,
colour: 140,
tooltip: 'A transition triggered by an event',
}, },
]); ]);
@@ -61,18 +89,36 @@ function readScenarioSteps(workspace: Blockly.WorkspaceSvg): ScenarioStep[] {
const steps: ScenarioStep[] = []; const steps: ScenarioStep[] = [];
const seenBlocks = new Set<string>(); const seenBlocks = new Set<string>();
const readTransitions = (block: Blockly.Block): ScenarioStep['transitions'] => {
const transitions: NonNullable<ScenarioStep['transitions']> = [];
let transitionBlock = block.getInputTargetBlock('TRANSITIONS');
while (transitionBlock) {
if (transitionBlock.type === 'zacus_transition') {
transitions.push({
eventType: transitionBlock.getFieldValue('EVENT_TYPE') ?? 'serial',
eventName: transitionBlock.getFieldValue('EVENT_NAME') ?? 'BTN_NEXT',
targetStepId: transitionBlock.getFieldValue('TARGET') ?? 'STEP_NEXT',
afterMs: Number(transitionBlock.getFieldValue('AFTER_MS') ?? 0),
priority: Number(transitionBlock.getFieldValue('PRIORITY') ?? 0),
});
}
transitionBlock = transitionBlock.getNextBlock();
}
return transitions;
};
const collectChain = (startBlock: Blockly.Block): void => { const collectChain = (startBlock: Blockly.Block): void => {
let cursor: Blockly.Block | null = startBlock; let cursor: Blockly.Block | null = startBlock;
while (cursor) { while (cursor) {
if (seenBlocks.has(cursor.id)) { if (seenBlocks.has(cursor.id)) break;
break;
}
seenBlocks.add(cursor.id); seenBlocks.add(cursor.id);
if (cursor.type === 'zacus_step') { if (cursor.type === 'zacus_step') {
steps.push({ steps.push({
stepId: cursor.getFieldValue('STEP_ID') ?? '', stepId: cursor.getFieldValue('STEP_ID') ?? '',
sceneId: cursor.getFieldValue('SCENE_ID') ?? '', sceneId: cursor.getFieldValue('SCENE_ID') ?? '',
audioPack: cursor.getFieldValue('AUDIO_PACK') ?? '',
transitions: readTransitions(cursor),
}); });
} }
cursor = cursor.getNextBlock(); cursor = cursor.getNextBlock();
@@ -80,72 +126,129 @@ function readScenarioSteps(workspace: Blockly.WorkspaceSvg): ScenarioStep[] {
}; };
for (const block of workspace.getTopBlocks(true)) { for (const block of workspace.getTopBlocks(true)) {
if (block.type === 'zacus_step') { if (block.type === 'zacus_step') collectChain(block);
collectChain(block);
}
} }
if (steps.length === 0) { if (steps.length === 0) {
for (const block of workspace.getAllBlocks(false)) { for (const block of workspace.getAllBlocks(false)) {
if (block.type === 'zacus_step') { if (block.type === 'zacus_step') collectChain(block);
collectChain(block);
}
} }
} }
return steps; // Deduplicate step IDs — keep first occurrence, warn on duplicates
const seenStepIds = new Set<string>();
const deduped: ScenarioStep[] = [];
for (const step of steps) {
if (seenStepIds.has(step.stepId)) {
console.warn(
`[BlocklyDesigner] Duplicate step ID "${step.stepId}" detected — skipping duplicate.`,
);
continue;
}
seenStepIds.add(step.stepId);
deduped.push(step);
}
return deduped;
} }
function addStarterBlocks(workspace: Blockly.WorkspaceSvg): void { function loadStepsIntoWorkspace(
const first = workspace.newBlock('zacus_step'); workspace: Blockly.WorkspaceSvg,
first.setFieldValue('STEP_U_SON_PROTO', 'STEP_ID'); steps: ScenarioStep[],
first.setFieldValue('SCENE_U_SON_PROTO', 'SCENE_ID'); ): void {
first.initSvg(); workspace.clear();
first.render(); let prevBlock: Blockly.Block | null = null;
first.moveBy(40, 40); const Y_START = 40;
const X_START = 40;
const second = workspace.newBlock('zacus_step'); for (const step of steps) {
second.setFieldValue('STEP_LA_DETECTOR', 'STEP_ID'); const block = workspace.newBlock('zacus_step');
second.setFieldValue('SCENE_LA_DETECTOR', 'SCENE_ID'); block.setFieldValue(step.stepId || 'STEP_NEW', 'STEP_ID');
second.initSvg(); block.setFieldValue(step.sceneId || 'SCENE_NEW', 'SCENE_ID');
second.render(); block.setFieldValue(step.audioPack || '', 'AUDIO_PACK');
block.initSvg();
block.render();
if (first.nextConnection && second.previousConnection) { const transitions = step.transitions ?? [];
first.nextConnection.connect(second.previousConnection); let previousTransitionBlock: Blockly.Block | null = null;
for (const transition of transitions) {
const transitionBlock = workspace.newBlock('zacus_transition');
transitionBlock.setFieldValue(transition.eventType, 'EVENT_TYPE');
transitionBlock.setFieldValue(transition.eventName, 'EVENT_NAME');
transitionBlock.setFieldValue(transition.targetStepId, 'TARGET');
transitionBlock.setFieldValue(String(transition.afterMs), 'AFTER_MS');
transitionBlock.setFieldValue(String(transition.priority), 'PRIORITY');
transitionBlock.initSvg();
transitionBlock.render();
if (!previousTransitionBlock) {
const inputConnection = block.getInput('TRANSITIONS')?.connection;
if (inputConnection && transitionBlock.previousConnection) {
inputConnection.connect(transitionBlock.previousConnection);
}
} else if (
previousTransitionBlock.nextConnection &&
transitionBlock.previousConnection
) {
previousTransitionBlock.nextConnection.connect(
transitionBlock.previousConnection,
);
}
previousTransitionBlock = transitionBlock;
}
if (prevBlock) {
if (prevBlock.nextConnection && block.previousConnection) {
prevBlock.nextConnection.connect(block.previousConnection);
}
} else {
block.moveBy(X_START, Y_START);
}
prevBlock = block;
} }
} }
function addStarterBlocks(workspace: Blockly.WorkspaceSvg): void {
loadStepsIntoWorkspace(workspace, [
{
stepId: 'STEP_U_SON_PROTO',
sceneId: 'SCENE_U_SON_PROTO',
transitions: [
{
eventType: 'button',
eventName: 'ANY',
targetStepId: 'STEP_LA_DETECTOR',
priority: 0,
afterMs: 0,
},
],
},
{ stepId: 'STEP_LA_DETECTOR', sceneId: 'SCENE_LA_DETECTOR', transitions: [] },
]);
}
type BlocklyDesignerProps = { type BlocklyDesignerProps = {
onYamlChange: (yaml: string) => void; onDraftChange: (draft: { yaml: string; runtime3Json: string }) => void;
}; };
export function BlocklyDesigner({ onYamlChange }: BlocklyDesignerProps) { export function BlocklyDesigner({ onDraftChange }: BlocklyDesignerProps) {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null); const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const [scenarioId, setScenarioId] = useState('zacus_v2_new'); const [scenarioId, setScenarioId] = useState('zacus_v2_new');
const [steps, setSteps] = useState<ScenarioStep[]>([]); const [steps, setSteps] = useState<ScenarioStep[]>([]);
const [copyInfo, setCopyInfo] = useState(''); const [copyInfo, setCopyInfo] = useState('');
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
if (!hostRef.current) { if (!hostRef.current) return;
return;
}
ensureCustomBlocks(); ensureCustomBlocks();
const workspace = Blockly.inject(hostRef.current, { const workspace = Blockly.inject(hostRef.current, {
toolbox: TOOLBOX, toolbox: TOOLBOX,
trashcan: true, trashcan: true,
grid: { grid: { spacing: 20, length: 3, colour: '#3a3f52', snap: true },
spacing: 20, zoom: { controls: true, wheel: true, startScale: 0.95 },
length: 3,
colour: '#d0d7de',
snap: true,
},
zoom: {
controls: true,
wheel: true,
startScale: 0.95,
},
}); });
workspaceRef.current = workspace; workspaceRef.current = workspace;
addStarterBlocks(workspace); addStarterBlocks(workspace);
@@ -167,21 +270,25 @@ export function BlocklyDesigner({ onYamlChange }: BlocklyDesignerProps) {
const generated = useMemo(() => { const generated = useMemo(() => {
const scenarioDocument = buildScenarioFromBlocks(scenarioId, steps); const scenarioDocument = buildScenarioFromBlocks(scenarioId, steps);
const runtime3Document = compileScenarioDocumentToRuntime3(scenarioDocument);
return { return {
scenarioDocument,
yaml: scenarioToYaml(scenarioDocument), yaml: scenarioToYaml(scenarioDocument),
validation: validateScenarioDocument(scenarioDocument), validation: validateScenarioDocument(scenarioDocument),
runtime3Json: runtime3ToJson(runtime3Document),
runtime3Validation: validateRuntime3Document(runtime3Document),
}; };
}, [scenarioId, steps]); }, [scenarioId, steps]);
useEffect(() => { useEffect(() => {
onYamlChange(generated.yaml); onDraftChange({
}, [generated.yaml, onYamlChange]); yaml: generated.yaml,
runtime3Json: generated.runtime3Json,
});
}, [generated.runtime3Json, generated.yaml, onDraftChange]);
const handleReset = () => { const handleReset = () => {
if (!workspaceRef.current) { if (!workspaceRef.current) return;
return;
}
workspaceRef.current.clear();
addStarterBlocks(workspaceRef.current); addStarterBlocks(workspaceRef.current);
setSteps(readScenarioSteps(workspaceRef.current)); setSteps(readScenarioSteps(workspaceRef.current));
}; };
@@ -195,6 +302,37 @@ export function BlocklyDesigner({ onYamlChange }: BlocklyDesignerProps) {
} }
}; };
const handleImportYaml = (yamlStr: string) => {
const result = parseYamlToSteps(yamlStr);
if ('error' in result) {
setCopyInfo(`Import error: ${result.error}`);
return;
}
setScenarioId(result.id);
if (workspaceRef.current) {
loadStepsIntoWorkspace(workspaceRef.current, result.steps);
setSteps(readScenarioSteps(workspaceRef.current));
}
setCopyInfo(`Imported ${result.steps.length} steps from "${result.id}".`);
};
const handleFileImport = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
handleImportYaml(reader.result);
}
};
reader.readAsText(file);
e.target.value = '';
};
return ( return (
<> <>
<div className="toolbar"> <div className="toolbar">
@@ -206,22 +344,45 @@ export function BlocklyDesigner({ onYamlChange }: BlocklyDesignerProps) {
placeholder="scenario id" placeholder="scenario id"
/> />
<button type="button" onClick={handleReset}> <button type="button" onClick={handleReset}>
Reset blocks Reset
</button> </button>
<button type="button" onClick={handleCopy}> <button type="button" onClick={handleCopy}>
Copy YAML Copy YAML
</button> </button>
<button type="button" onClick={handleFileImport}>
Import YAML
</button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div> </div>
<div ref={hostRef} className="blockly-host" /> <div ref={hostRef} className="blockly-host" />
<div <div
className={`status-line ${generated.validation.ok ? '' : 'error'}`} className={`status-line ${
generated.validation.ok && generated.runtime3Validation.ok ? '' : 'error'
}`}
role="status" role="status"
> >
{generated.validation.ok {generated.validation.ok && generated.runtime3Validation.ok
? `Ready - ${steps.length} step(s) detected. ${copyInfo}` ? `Ready ${steps.length} step(s), ${
: `Invalid draft: ${generated.validation.error}`} steps.reduce(
(total, step) => total + (step.transitions?.length ?? 0),
0,
)
} transition(s). ${copyInfo}`
: `Invalid: ${
!generated.validation.ok
? generated.validation.error
: generated.runtime3Validation.ok
? 'runtime3 validation error'
: generated.runtime3Validation.error
}`}
</div> </div>
</> </>
); );
@@ -0,0 +1,534 @@
import { useEffect, useRef, useState } from 'react';
import {
type ScenarioListItem,
type VoiceStatus,
type GameAnalytics,
storyList,
storySelect,
storyStart,
storyPause,
storyResume,
storySkip,
askHint,
voiceStatus,
gameAnalytics,
} from '../lib/api';
import type { RuntimeState } from '../lib/useRuntimeStore';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ChatMessage {
role: 'user' | 'zacus';
text: string;
timestamp: number;
}
type Props = { runtime: RuntimeState; refresh: () => void };
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function Dashboard({ runtime, refresh }: Props) {
const [scenarios, setScenarios] = useState<ScenarioListItem[]>([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState('');
const [voiceInput, setVoiceInput] = useState('');
const [voice, setVoice] = useState<VoiceStatus | null>(null);
// Chat history
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [chatLoading, setChatLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Hint level
const [hintLevel, setHintLevel] = useState<number>(1);
// Analytics
const [analytics, setAnalytics] = useState<GameAnalytics | null>(null);
const [analyticsError, setAnalyticsError] = useState('');
// ─── Initial load ───
useEffect(() => {
storyList()
.then(setScenarios)
.catch(() => setScenarios([]));
voiceStatus()
.then(setVoice)
.catch(() => setVoice(null));
}, [runtime.connected]);
// ─── Auto-poll voice status every 5s when connected ───
useEffect(() => {
if (!runtime.connected) return;
const id = setInterval(() => {
voiceStatus().then(setVoice).catch(() => {});
}, 5000);
return () => clearInterval(id);
}, [runtime.connected]);
// ─── Scroll chat to bottom ───
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatHistory, chatLoading]);
// ─── Helpers ───
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(true);
setFeedback('');
try {
await fn();
setFeedback(`${label} OK`);
refresh();
} catch (err) {
setFeedback(
`${label} failed: ${err instanceof Error ? err.message : err}`,
);
} finally {
setBusy(false);
}
};
const handleAskZacus = async () => {
const text = voiceInput.trim();
if (!text) return;
const userMsg: ChatMessage = { role: 'user', text, timestamp: Date.now() };
setChatHistory((prev) => [...prev, userMsg]);
setVoiceInput('');
setChatLoading(true);
try {
const puzzleId = runtime.story?.current_step || 'general';
const res = await askHint(puzzleId, text, hintLevel);
const zacusMsg: ChatMessage = {
role: 'zacus',
text: res.hint,
timestamp: Date.now(),
};
setChatHistory((prev) => [...prev, zacusMsg]);
} catch (err) {
const errorMsg: ChatMessage = {
role: 'zacus',
text: `[Error] ${err instanceof Error ? err.message : String(err)}`,
timestamp: Date.now(),
};
setChatHistory((prev) => [...prev, errorMsg]);
} finally {
setChatLoading(false);
}
};
const refreshAnalytics = () => {
setAnalyticsError('');
gameAnalytics()
.then(setAnalytics)
.catch((err) =>
setAnalyticsError(err instanceof Error ? err.message : String(err)),
);
};
const formatDuration = (ms: number) => {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}m ${sec}s`;
};
const story = runtime.story;
const legacy = runtime.legacy;
const net = legacy?.network;
const runtime3 = legacy?.runtime3;
return (
<div className="dashboard">
<h2>Dashboard</h2>
{/* Connection */}
<section className="card">
<h3>Connection</h3>
<div className="kv">
<span>Status</span>
<span className={runtime.connected ? 'badge ok' : 'badge err'}>
{runtime.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{net && (
<>
<div className="kv">
<span>Network</span>
<span>{net.state ?? '?'}</span>
</div>
<div className="kv">
<span>IP</span>
<span>{net.ip ?? '?'}</span>
</div>
</>
)}
</section>
{/* Story status */}
<section className="card">
<h3>Story runtime</h3>
{story ? (
<>
<div className="kv">
<span>Status</span>
<span className="badge">{story.status}</span>
</div>
<div className="kv">
<span>Scenario</span>
<span>{story.scenario_id || '\u2014'}</span>
</div>
<div className="kv">
<span>Step</span>
<span className="mono">{story.current_step || '\u2014'}</span>
</div>
<div className="kv">
<span>Progress</span>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${story.progress_pct}%` }}
/>
<span>{story.progress_pct}%</span>
</div>
</div>
<div className="kv">
<span>Queue</span>
<span>{story.queue_depth}</span>
</div>
</>
) : (
<p className="muted">No Story V2 status available.</p>
)}
</section>
<section className="card">
<h3>Runtime 3 adapter</h3>
{runtime3 ? (
<div className="runtime3-grid">
<div className="kv">
<span>Contract</span>
<span className="mono">
{legacy?.story.runtime_contract ?? 'story_v2'}
</span>
</div>
<div className="kv">
<span>Status</span>
<span
className={`badge ${
runtime3.loaded ? 'ok' : runtime3.discovered ? 'active' : 'err'
}`}
>
{runtime3.loaded
? 'Loaded'
: runtime3.discovered
? 'Discovered'
: 'Missing'}
</span>
</div>
<div className="kv">
<span>Scenario</span>
<span className="mono">{runtime3.scenario_id || '\u2014'}</span>
</div>
<div className="kv">
<span>Version</span>
<span>{runtime3.scenario_version || 0}</span>
</div>
<div className="kv">
<span>Entry step</span>
<span className="mono">{runtime3.entry_step_id || '\u2014'}</span>
</div>
<div className="kv">
<span>Migration</span>
<span className="mono">{runtime3.migration_mode || '\u2014'}</span>
</div>
<div className="kv">
<span>Steps</span>
<span>{runtime3.step_count}</span>
</div>
<div className="kv">
<span>Transitions</span>
<span>{runtime3.transition_count}</span>
</div>
<div className="kv">
<span>Source</span>
<span className="mono">{runtime3.source_kind || '\u2014'}</span>
</div>
<div className="kv">
<span>Schema</span>
<span className="mono">{runtime3.schema_version || '\u2014'}</span>
</div>
<div className="kv runtime3-span">
<span>Bundle</span>
<span className="mono runtime3-path">{runtime3.path || '\u2014'}</span>
</div>
{runtime3.error && (
<div className="notice warn runtime3-span">
Runtime 3 error: {runtime3.error}
</div>
)}
</div>
) : (
<p className="muted">No Runtime 3 adapter metadata available.</p>
)}
</section>
{/* Scenarios */}
<section className="card">
<h3>Scenarios</h3>
{scenarios.length === 0 ? (
<p className="muted">No scenarios found.</p>
) : (
<ul className="scenario-list">
{scenarios.map((s) => (
<li key={s.id}>
<span className="mono">{s.id}</span>
<span className="muted">v{s.version}</span>
<button
type="button"
disabled={busy || story?.selected === s.id}
onClick={() => run('Select', () => storySelect(s.id))}
>
{story?.selected === s.id ? 'Selected' : 'Select'}
</button>
</li>
))}
</ul>
)}
</section>
{/* Controls */}
<section className="card">
<h3>Controls</h3>
<div className="actions">
<button
type="button"
disabled={busy || story?.status === 'running'}
onClick={() => run('Start', storyStart)}
>
Start
</button>
<button
type="button"
disabled={busy || story?.status !== 'running'}
onClick={() => run('Pause', storyPause)}
>
Pause
</button>
<button
type="button"
disabled={busy || story?.status !== 'paused'}
onClick={() => run('Resume', storyResume)}
>
Resume
</button>
<button
type="button"
disabled={busy || story?.status !== 'running'}
onClick={() => run('Skip', storySkip)}
>
Skip
</button>
</div>
{feedback && <p className="feedback">{feedback}</p>}
</section>
{/* Voice — Ask Professor Zacus (enhanced chat) */}
<section className="card">
<h3>Ask Professor Zacus</h3>
<div className="kv">
<span>Voice Bridge</span>
<span className={voice?.connected ? 'badge ok' : 'badge err'}>
{voice?.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Hint level selector */}
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', margin: '0.5rem 0' }}>
<span style={{ fontSize: '0.85em', fontWeight: 500 }}>Hint level:</span>
{[1, 2, 3].map((lvl) => (
<label key={lvl} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.85em', cursor: 'pointer' }}>
<input
type="radio"
name="hintLevel"
value={lvl}
checked={hintLevel === lvl}
onChange={() => setHintLevel(lvl)}
/>
{lvl === 1 ? 'Gentle' : lvl === 2 ? 'Medium' : 'Direct'}
</label>
))}
</div>
{/* Chat history */}
<div
style={{
maxHeight: '240px',
overflowY: 'auto',
border: '1px solid var(--border, #ddd)',
borderRadius: '6px',
padding: '0.5rem',
marginBottom: '0.5rem',
background: 'var(--bg-muted, #fafafa)',
}}
>
{chatHistory.length === 0 && !chatLoading && (
<p className="muted" style={{ textAlign: 'center', margin: '1rem 0', fontSize: '0.85em' }}>
Ask Professor Zacus a question about the puzzle...
</p>
)}
{chatHistory.map((msg, i) => (
<div
key={i}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: '0.4rem',
}}
>
<div
style={{
maxWidth: '80%',
padding: '0.4rem 0.6rem',
borderRadius: '8px',
background: msg.role === 'user'
? 'var(--accent, #6c5ce7)'
: 'var(--bg-card, #fff)',
color: msg.role === 'user' ? '#fff' : 'inherit',
border: msg.role === 'zacus' ? '1px solid var(--border, #ddd)' : 'none',
fontSize: '0.9em',
whiteSpace: 'pre-wrap',
}}
>
{msg.role === 'zacus' && (
<strong style={{ fontSize: '0.8em', display: 'block', marginBottom: '0.15rem' }}>
Prof. Zacus
</strong>
)}
{msg.text}
</div>
<span style={{ fontSize: '0.7em', color: '#999', marginTop: '0.1rem' }}>
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
{chatLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.4rem' }}>
<span
className="spinner"
style={{
display: 'inline-block',
width: '14px',
height: '14px',
border: '2px solid var(--border, #ddd)',
borderTopColor: 'var(--accent, #6c5ce7)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
<span style={{ fontSize: '0.85em', color: '#999' }}>Professor Zacus is thinking...</span>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Hint count */}
{analytics && (
<div style={{ fontSize: '0.8em', color: '#888', marginBottom: '0.3rem' }}>
Hints used this session: {analytics.total_hints}
</div>
)}
{/* Input */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text"
placeholder="Ask a question..."
aria-label="Voice query"
value={voiceInput}
onChange={(e) => setVoiceInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && voiceInput.trim() && !chatLoading) {
handleAskZacus();
}
}}
disabled={chatLoading}
style={{ flex: 1 }}
/>
<button
type="button"
disabled={chatLoading || !voiceInput.trim()}
onClick={handleAskZacus}
>
Ask
</button>
</div>
</section>
{/* Analytics */}
<section className="card">
<h3>
Analytics{' '}
<button
type="button"
onClick={refreshAnalytics}
style={{ fontSize: '0.75em', marginLeft: '0.5rem', padding: '0.15rem 0.5rem' }}
>
Refresh
</button>
</h3>
{analyticsError && <p className="error-banner">{analyticsError}</p>}
{analytics ? (
<div className="runtime3-grid">
<div className="kv">
<span>Session</span>
<span className="mono">{analytics.session_id}</span>
</div>
<div className="kv">
<span>Duration</span>
<span>{formatDuration(analytics.duration_ms)}</span>
</div>
<div className="kv">
<span>Puzzles solved</span>
<span>
{analytics.puzzles_solved} / {analytics.puzzles.length}
</span>
</div>
<div className="kv">
<span>Hints used</span>
<span>{analytics.total_hints}</span>
</div>
<div className="kv">
<span>Total attempts</span>
<span>{analytics.total_attempts}</span>
</div>
</div>
) : (
<p className="muted">No analytics loaded. Press Refresh to fetch.</p>
)}
</section>
{/* Last WS event */}
{runtime.lastEvent && (
<section className="card">
<h3>Last event</h3>
<pre className="event-json">
{JSON.stringify(runtime.lastEvent, null, 2)}
</pre>
</section>
)}
{runtime.lastError && (
<p className="error-banner">{runtime.lastError}</p>
)}
{/* Spinner keyframe (injected once) */}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useState } from 'react';
import {
type MediaFileList,
type MediaStatus,
mediaFiles,
mediaPlay,
mediaStop,
mediaRecordStart,
mediaRecordStop,
legacyControl,
} from '../lib/api';
import type { RuntimeState } from '../lib/useRuntimeStore';
import { isMediaManagerActive } from '../lib/useRuntimeStore';
type Props = { runtime: RuntimeState };
type FileCategory = 'music' | 'picture' | 'recorder';
const CATEGORIES: FileCategory[] = ['music', 'picture', 'recorder'];
export function MediaManager({ runtime }: Props) {
const [listings, setListings] = useState<
Record<FileCategory, MediaFileList | null>
>({
music: null,
picture: null,
recorder: null,
});
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState('');
const [recSeconds, setRecSeconds] = useState(20);
const [recFilename, setRecFilename] = useState('take_1.wav');
const media: MediaStatus | undefined = runtime.legacy?.media;
const active = isMediaManagerActive(runtime.legacy);
const loadFiles = useCallback(async () => {
const results = await Promise.allSettled(
CATEGORIES.map((kind) => mediaFiles(kind)),
);
const next = { ...listings };
CATEGORIES.forEach((kind, i) => {
const r = results[i];
next[kind] = r.status === 'fulfilled' ? r.value : null;
});
setListings(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (active) loadFiles();
}, [active, loadFiles]);
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(true);
setFeedback('');
try {
await fn();
setFeedback(`${label} OK`);
} catch (err) {
setFeedback(
`${label} failed: ${err instanceof Error ? err.message : err}`,
);
} finally {
setBusy(false);
}
};
const handlePlay = (path: string) =>
run('Play', async () => {
if (media?.playing) await mediaStop();
await mediaPlay(path);
});
const handleStop = () => run('Stop', mediaStop);
const handleRecordStart = () =>
run('Record', () => mediaRecordStart(recSeconds, recFilename));
const handleRecordStop = () => run('RecStop', mediaRecordStop);
return (
<div className="media-manager">
<h2>Media Manager</h2>
{!active && (
<div className="notice warn">
Media Manager not active on device. Current step:{' '}
<span className="mono">
{runtime.legacy?.story.screen ?? runtime.legacy?.story.step ?? '?'}
</span>
</div>
)}
{/* Media status */}
{media && (
<section className="card">
<h3>Status</h3>
<div className="kv">
<span>Ready</span>
<span className={media.ready ? 'badge ok' : 'badge err'}>
{String(media.ready)}
</span>
</div>
<div className="kv">
<span>Playing</span>
<span className={media.playing ? 'badge active' : 'badge'}>
{String(media.playing)}
</span>
</div>
<div className="kv">
<span>Recording</span>
<span className={media.recording ? 'badge active' : 'badge'}>
{String(media.recording)}
</span>
</div>
{media.recording && (
<div className="kv">
<span>Elapsed</span>
<span>
{media.record_elapsed_seconds}s / {media.record_limit_seconds}s
</span>
</div>
)}
{media.record_simulated && (
<div className="notice info">
Recording is simulated (placeholder WAV).
</div>
)}
{media.last_error && (
<div className="notice err">
Last error: {media.last_error}
</div>
)}
</section>
)}
{/* File listings */}
{CATEGORIES.map((kind) => {
const list = listings[kind];
return (
<section className="card" key={kind}>
<h3>{kind.charAt(0).toUpperCase() + kind.slice(1)}</h3>
{!list ? (
<p className="muted">
Not loaded.{' '}
<button
type="button"
onClick={() =>
mediaFiles(kind).then((r) =>
setListings((prev) => ({ ...prev, [kind]: r })),
)
}
>
Load
</button>
</p>
) : list.files.length === 0 ? (
<p className="muted">No files.</p>
) : (
<ul className="file-list">
{list.files.map((f) => (
<li key={f}>
<span className="mono">{f}</span>
{kind === 'music' && (
<button
type="button"
disabled={busy}
onClick={() => handlePlay(f)}
>
Play
</button>
)}
</li>
))}
</ul>
)}
</section>
);
})}
{/* Playback controls */}
<section className="card">
<h3>Playback</h3>
<div className="actions">
<button
type="button"
disabled={busy || !media?.playing}
onClick={handleStop}
>
Stop
</button>
<button type="button" disabled={busy} onClick={loadFiles}>
Refresh files
</button>
</div>
</section>
{/* Recording */}
<section className="card">
<h3>Recording</h3>
<div className="rec-form">
<label>
Duration (s)
<input
type="number"
min={1}
max={120}
value={recSeconds}
onChange={(e) => setRecSeconds(Number(e.target.value))}
/>
</label>
<label>
Filename
<input
type="text"
value={recFilename}
onChange={(e) => setRecFilename(e.target.value)}
/>
</label>
</div>
<div className="actions">
<button
type="button"
disabled={busy || !!media?.recording}
onClick={handleRecordStart}
>
Record
</button>
<button
type="button"
disabled={busy || !media?.recording}
onClick={handleRecordStop}
>
Stop rec
</button>
</div>
</section>
{/* Boot mode */}
<section className="card">
<h3>Boot mode</h3>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => run('BootStatus', () => legacyControl('BOOT_MODE_STATUS'))}
>
Status
</button>
<button
type="button"
disabled={busy}
onClick={() =>
run('SetMedia', () =>
legacyControl('BOOT_MODE_SET MEDIA_MANAGER'),
)
}
>
Set Media
</button>
<button
type="button"
disabled={busy}
onClick={() =>
run('SetStory', () => legacyControl('BOOT_MODE_SET STORY'))
}
>
Set Story
</button>
<button
type="button"
disabled={busy}
onClick={() => run('Clear', () => legacyControl('BOOT_MODE_CLEAR'))}
>
Clear
</button>
</div>
</section>
{feedback && <p className="feedback">{feedback}</p>}
</div>
);
}
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { networkReconnect, espnowOn, espnowOff } from '../lib/api';
import type { RuntimeState } from '../lib/useRuntimeStore';
type Props = { runtime: RuntimeState };
export function NetworkPanel({ runtime }: Props) {
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState('');
const net = runtime.legacy?.network;
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(true);
setFeedback('');
try {
await fn();
setFeedback(`${label} OK`);
} catch (err) {
setFeedback(
`${label} failed: ${err instanceof Error ? err.message : err}`,
);
} finally {
setBusy(false);
}
};
return (
<div className="network-panel">
<h2>Network</h2>
<section className="card">
<h3>WiFi</h3>
<div className="kv">
<span>State</span>
<span className="badge">{net?.state ?? '?'}</span>
</div>
<div className="kv">
<span>IP</span>
<span className="mono">{net?.ip ?? '?'}</span>
</div>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => run('Reconnect', networkReconnect)}
>
Reconnect WiFi
</button>
</div>
</section>
<section className="card">
<h3>ESP-NOW</h3>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => run('ESP-NOW On', espnowOn)}
>
Enable
</button>
<button
type="button"
disabled={busy}
onClick={() => run('ESP-NOW Off', espnowOff)}
>
Disable
</button>
</div>
</section>
{feedback && <p className="feedback">{feedback}</p>}
</div>
);
}
@@ -85,6 +85,20 @@ export function RuntimeControls({ yaml }: RuntimeControlsProps) {
> >
Status Status
</button> </button>
<button
type="button"
disabled={busy}
onClick={() => runRequest('runtime3 status', '/api/runtime3/status')}
>
Runtime 3 Status
</button>
<button
type="button"
disabled={busy}
onClick={() => runRequest('runtime3 document', '/api/runtime3/document')}
>
Runtime 3 Document
</button>
<button <button
type="button" type="button"
disabled={busy || yaml.trim().length === 0} disabled={busy || yaml.trim().length === 0}
+1 -7
View File
@@ -1,20 +1,14 @@
:root { :root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial, sans-serif; Helvetica, Arial, sans-serif;
line-height: 1.4; line-height: 1.5;
font-weight: 400; font-weight: 400;
color: #0f172a;
background: #f5f7fa;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
+407
View File
@@ -0,0 +1,407 @@
/**
* API client aligned with STORY_RUNTIME_API_JSON_CONTRACT.md
* Supports Story V2 + Legacy Freenove endpoints.
*/
const DEFAULT_BASE =
(import.meta.env.VITE_STORY_API_BASE as string | undefined) ??
'http://localhost:8080';
let baseUrl = DEFAULT_BASE;
export function setApiBase(url: string) {
baseUrl = url.replace(/\/+$/, '');
}
export function getApiBase() {
return baseUrl;
}
// ─── Helpers ───
async function jsonFetch<T>(path: string, init?: RequestInit, timeoutMs = 5000): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(`${baseUrl}${path}`, {
...init,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError(0, `Request timed out after ${timeoutMs}ms`, path);
}
throw err;
}
clearTimeout(timeoutId);
const text = await res.text();
if (!text) throw new ApiError(res.status, 'empty response', path);
const data = JSON.parse(text);
if (!res.ok) {
const msg = data?.error?.message ?? data?.error ?? res.statusText;
throw new ApiError(res.status, msg, path);
}
if (data?.ok === false) {
throw new ApiError(res.status, data.error ?? 'operation failed', path);
}
return data as T;
}
export class ApiError extends Error {
status: number;
path: string;
constructor(
status: number,
message: string,
path: string,
) {
super(message);
this.name = 'ApiError';
this.status = status;
this.path = path;
}
}
// ─── Story V2 ───
export interface ScenarioListItem {
id: string;
version: number;
estimated_duration_s: number;
}
export interface StoryStatus {
status: 'idle' | 'running' | 'paused';
scenario_id: string;
current_step: string;
progress_pct: number;
started_at_ms: number;
selected: string;
queue_depth: number;
}
export async function storyList(): Promise<ScenarioListItem[]> {
const data = await jsonFetch<{ scenarios: ScenarioListItem[] }>(
'/api/story/list',
);
return data.scenarios;
}
export async function storyStatus(): Promise<StoryStatus> {
return jsonFetch<StoryStatus>('/api/story/status');
}
export async function storySelect(scenarioId: string) {
return jsonFetch<{ selected: string; status: string }>(
`/api/story/select/${encodeURIComponent(scenarioId)}`,
{ method: 'POST' },
);
}
export async function storyStart() {
return jsonFetch<{ status: string; current_step: string }>(
'/api/story/start',
{ method: 'POST' },
);
}
export async function storyPause() {
return jsonFetch<{ status: string }>('/api/story/pause', { method: 'POST' });
}
export async function storyResume() {
return jsonFetch<{ status: string }>('/api/story/resume', { method: 'POST' });
}
export async function storySkip() {
return jsonFetch<{ previous_step: string; current_step: string }>(
'/api/story/skip',
{ method: 'POST' },
);
}
export async function storyValidate(yaml: string) {
return jsonFetch<{ valid: boolean }>('/api/story/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yaml }),
});
}
export async function storyDeploy(yaml: string) {
return jsonFetch<{ deployed: string; status: string }>('/api/story/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/x-yaml' },
body: yaml,
});
}
// ─── Legacy / Status ───
export interface LegacyStatus {
story: {
scenario: string;
step: string;
screen?: string;
audio_pack?: string;
runtime_contract?: string;
};
runtime3?: Runtime3FirmwareStatus;
network?: {
state?: string;
ip?: string;
};
media?: MediaStatus;
}
export interface Runtime3FirmwareStatus {
discovered: boolean;
loaded: boolean;
path: string;
schema_version: string;
scenario_id: string;
scenario_version: number;
entry_step_id: string;
source_kind: string;
generated_by: string;
migration_mode: string;
step_count: number;
transition_count: number;
size_bytes: number;
error: string;
}
export interface MediaStatus {
ready: boolean;
playing: boolean;
recording: boolean;
record_limit_seconds: number;
record_elapsed_seconds: number;
record_file: string;
record_simulated: boolean;
music_dir: string;
picture_dir: string;
record_dir: string;
last_ok: boolean;
last_error: string;
}
export async function legacyStatus(): Promise<LegacyStatus> {
return jsonFetch<LegacyStatus>('/api/status');
}
export async function runtime3Status(): Promise<Runtime3FirmwareStatus> {
return jsonFetch<Runtime3FirmwareStatus>('/api/runtime3/status');
}
export async function runtime3Document(): Promise<Record<string, unknown>> {
return jsonFetch<Record<string, unknown>>('/api/runtime3/document');
}
// ─── Media ───
export interface MediaFileList {
ok: boolean;
kind: string;
files: string[];
}
export async function mediaFiles(
kind: 'music' | 'picture' | 'recorder',
): Promise<MediaFileList> {
return jsonFetch<MediaFileList>(
`/api/media/files?kind=${encodeURIComponent(kind)}`,
);
}
export async function mediaPlay(path: string) {
return jsonFetch<{ action: string; ok: boolean }>('/api/media/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
}
export async function mediaStop() {
return jsonFetch<{ action: string; ok: boolean }>('/api/media/stop', {
method: 'POST',
});
}
export async function mediaRecordStart(seconds: number, filename: string) {
return jsonFetch<{ action: string; ok: boolean }>(
'/api/media/record/start',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seconds, filename }),
},
);
}
export async function mediaRecordStop() {
return jsonFetch<{ action: string; ok: boolean }>('/api/media/record/stop', {
method: 'POST',
});
}
// ─── Network ───
export async function networkReconnect() {
return jsonFetch<{ action: string; ok: boolean }>(
'/api/network/wifi/reconnect',
{ method: 'POST' },
);
}
export async function espnowOn() {
return jsonFetch<{ action: string; ok: boolean }>('/api/network/espnow/on', {
method: 'POST',
});
}
export async function espnowOff() {
return jsonFetch<{ action: string; ok: boolean }>('/api/network/espnow/off', {
method: 'POST',
});
}
// ─── Control (legacy fallback) ───
export async function legacyControl(action: string) {
return jsonFetch<{ ok: boolean; action: string }>('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
}
// ─── WebSocket Stream ───
export type StreamMessage = {
type: 'status' | 'step_change' | 'transition' | 'audit_log';
timestamp: number;
data: Record<string, unknown>;
};
export function connectStoryStream(
onMessage: (msg: StreamMessage) => void,
onError?: (err: Event) => void,
): { close: () => void } {
const MAX_BACKOFF_MS = 8000;
let attempt = 0;
let ws: WebSocket | null = null;
let closed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (closed) return;
const wsUrl = baseUrl.replace(/^http/, 'ws') + '/api/story/stream';
ws = new WebSocket(wsUrl);
ws.onopen = () => {
attempt = 0;
};
ws.onmessage = (ev) => {
try {
onMessage(JSON.parse(ev.data));
} catch {
/* ignore parse errors */
}
};
ws.onerror = (err) => {
if (onError) onError(err);
};
ws.onclose = () => {
if (closed) return;
const delay = Math.min(1000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
attempt++;
reconnectTimer = setTimeout(connect, delay);
};
}
connect();
return {
close() {
closed = true;
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
ws?.close();
},
};
}
// ─── SSE Stream (legacy) ───
export function connectLegacyStream(
onStatus: (data: LegacyStatus) => void,
): EventSource {
const es = new EventSource(`${baseUrl}/api/stream`);
es.addEventListener('status', (ev) => {
try {
onStatus(JSON.parse((ev as MessageEvent).data));
} catch {
/* ignore */
}
});
return es;
}
// ---------------------------------------------------------------------------
// Voice pipeline
// ---------------------------------------------------------------------------
export interface VoiceStatus {
connected: boolean;
state: number;
has_audio: boolean;
last_response?: string;
}
export async function voiceStatus(): Promise<VoiceStatus> {
return jsonFetch<VoiceStatus>(`${baseUrl}/api/voice/status`);
}
export async function voiceQuery(text: string): Promise<{ status: string }> {
return jsonFetch<{ status: string }>(`${baseUrl}/api/voice/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
}
// ─── Analytics ───
export interface GameAnalytics {
session_id: string;
duration_ms: number;
puzzles_solved: number;
total_hints: number;
total_attempts: number;
puzzles: Array<{
puzzle_id: string;
solved: boolean;
attempts: number;
hints: number;
duration_ms: number;
}>;
}
export async function gameAnalytics(): Promise<GameAnalytics> {
return jsonFetch<GameAnalytics>(`${baseUrl}/api/analytics`);
}
// ─── Hints (via mascarade) ───
export async function askHint(puzzleId: string, question: string, hintLevel: number): Promise<{ hint: string; hint_count: number }> {
return jsonFetch<{ hint: string; hint_count: number }>(`${baseUrl}/api/voice/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `[HINT:${puzzleId}:${hintLevel}] ${question}` }),
});
}
+172
View File
@@ -0,0 +1,172 @@
import type { ScenarioDocument, StepTransition } from '../types';
export type Runtime3EventType =
| 'button'
| 'serial'
| 'timer'
| 'audio_done'
| 'unlock'
| 'espnow'
| 'action';
export interface Runtime3Transition {
id: string;
event_type: Runtime3EventType;
event_name: string;
target_step_id: string;
priority: number;
after_ms: number;
}
export interface Runtime3Step {
id: string;
scene_id: string;
audio_pack_id: string;
actions: string[];
apps: string[];
transitions: Runtime3Transition[];
}
export interface Runtime3Document {
schema_version: 'zacus.runtime3.v1';
scenario: {
id: string;
version: number;
title: string;
entry_step_id: string;
source_kind: 'studio';
};
steps: Runtime3Step[];
metadata: {
generated_by: 'frontend-scratch-v2';
migration_mode: 'native';
};
}
function normalizeToken(raw: string, fallback: string): string {
const cleaned = raw
.trim()
.replace(/[^a-zA-Z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
return cleaned || fallback;
}
function normalizeEventType(raw: string): Runtime3EventType {
const allowed: Runtime3EventType[] = [
'button',
'serial',
'timer',
'audio_done',
'unlock',
'espnow',
'action',
];
return allowed.includes(raw as Runtime3EventType)
? (raw as Runtime3EventType)
: 'serial';
}
function normalizeTransition(
transition: Pick<StepTransition, 'eventName' | 'targetStepId' | 'priority' | 'afterMs'> & {
eventType: string;
},
index: number,
): Runtime3Transition {
return {
id: `TR_${index + 1}_${normalizeToken(transition.targetStepId, 'STEP_NEXT')}`,
event_type: normalizeEventType(transition.eventType),
event_name: normalizeToken(transition.eventName, 'BTN_NEXT'),
target_step_id: normalizeToken(transition.targetStepId, 'STEP_NEXT'),
priority: transition.priority ?? 0,
after_ms: transition.afterMs ?? 0,
};
}
export function compileScenarioDocumentToRuntime3(
document: ScenarioDocument,
): Runtime3Document {
const steps = (document.firmware?.steps ?? []).map((step, index) => ({
id: normalizeToken(step.step_id, `STEP_${index + 1}`),
scene_id: normalizeToken(step.screen_scene_id, `SCENE_${index + 1}`),
audio_pack_id: normalizeToken(step.audio_pack_id, ''),
actions: step.actions ?? [],
apps: step.apps ?? [],
transitions: (step.transitions ?? []).map((transition, transitionIndex) =>
normalizeTransition(
{
eventType: transition.event_type,
eventName: transition.event_name,
targetStepId: transition.target_step_id,
priority: transition.priority,
afterMs: transition.after_ms,
},
transitionIndex,
),
),
}));
const entryStepId =
normalizeToken(
document.firmware?.initial_step ??
document.firmware?.steps?.[0]?.step_id ??
document.firmware?.steps_reference_order?.[0] ??
document.steps_narrative[0]?.step_id ??
'',
'STEP_BOOT',
) || 'STEP_BOOT';
return {
schema_version: 'zacus.runtime3.v1',
scenario: {
id: normalizeToken(document.id, 'ZACUS_RUNTIME3'),
version: document.version,
title: document.title,
entry_step_id: entryStepId,
source_kind: 'studio',
},
steps,
metadata: {
generated_by: 'frontend-scratch-v2',
migration_mode: 'native',
},
};
}
export function validateRuntime3Document(
document: Runtime3Document,
): { ok: true } | { ok: false; error: string } {
const stepIds = new Set<string>();
if (document.steps.length === 0) {
return { ok: false, error: 'runtime3 requires at least one step' };
}
for (const step of document.steps) {
if (stepIds.has(step.id)) {
return { ok: false, error: `duplicate runtime3 step id: ${step.id}` };
}
stepIds.add(step.id);
}
if (!stepIds.has(document.scenario.entry_step_id)) {
return { ok: false, error: 'entry_step_id does not exist in runtime3 steps' };
}
for (const step of document.steps) {
for (const transition of step.transitions) {
if (!stepIds.has(transition.target_step_id)) {
return {
ok: false,
error: `transition target missing: ${transition.target_step_id}`,
};
}
}
}
return { ok: true };
}
export function runtime3ToJson(document: Runtime3Document): string {
return JSON.stringify(document, null, 2);
}
+204 -19
View File
@@ -1,6 +1,6 @@
import YAML from 'yaml'; import YAML from 'yaml';
import { z } from 'zod'; import { z } from 'zod';
import type { ScenarioDocument, ScenarioStep } from '../types'; import type { ScenarioDocument, ScenarioStep, StepTransition } from '../types';
const scenarioSchema = z.object({ const scenarioSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
@@ -19,7 +19,6 @@ const scenarioSchema = z.object({
}), }),
stations: z.array(z.record(z.string(), z.unknown())), stations: z.array(z.record(z.string(), z.unknown())),
puzzles: z.array(z.record(z.string(), z.unknown())), puzzles: z.array(z.record(z.string(), z.unknown())),
steps_reference_order: z.array(z.string().min(1)),
steps_narrative: z.array( steps_narrative: z.array(
z.object({ z.object({
step_id: z.string().min(1), step_id: z.string().min(1),
@@ -27,9 +26,33 @@ const scenarioSchema = z.object({
narrative: z.string().min(1), narrative: z.string().min(1),
}), }),
), ),
firmware: z
.object({
initial_step: z.string().min(1),
steps_reference_order: z.array(z.string().min(1)).optional(),
steps: z.array(
z.object({
step_id: z.string().min(1),
screen_scene_id: z.string().min(1),
audio_pack_id: z.string(),
actions: z.array(z.string()),
apps: z.array(z.string()),
transitions: z.array(
z.object({
event_type: z.string().min(1),
event_name: z.string().min(1),
target_step_id: z.string().min(1),
priority: z.number().int(),
after_ms: z.number().int().min(0),
}),
),
}),
),
})
.optional(),
}); });
function normalizeId(raw: string, fallback: string): string { export function normalizeId(raw: string, fallback: string): string {
const cleaned = raw const cleaned = raw
.trim() .trim()
.replace(/[^a-zA-Z0-9_]+/g, '_') .replace(/[^a-zA-Z0-9_]+/g, '_')
@@ -38,6 +61,29 @@ function normalizeId(raw: string, fallback: string): string {
return cleaned || fallback; return cleaned || fallback;
} }
function normalizeTransition(transition: StepTransition): StepTransition {
return {
eventType: transition.eventType,
eventName: normalizeId(transition.eventName, 'BTN_NEXT'),
targetStepId: normalizeId(transition.targetStepId, 'STEP_NEXT'),
priority: transition.priority ?? 0,
afterMs: transition.afterMs ?? 0,
};
}
function resolveScenarioStepOrder(document: ScenarioDocument): string[] {
if (Array.isArray(document.firmware?.steps) && document.firmware.steps.length > 0) {
return document.firmware.steps.map((step) => step.step_id);
}
if (
Array.isArray(document.firmware?.steps_reference_order) &&
document.firmware.steps_reference_order.length > 0
) {
return document.firmware.steps_reference_order;
}
return document.steps_narrative.map((step) => step.step_id);
}
export function buildScenarioFromBlocks( export function buildScenarioFromBlocks(
scenarioId: string, scenarioId: string,
steps: ScenarioStep[], steps: ScenarioStep[],
@@ -47,37 +93,76 @@ export function buildScenarioFromBlocks(
return { return {
stepId: normalizeId(step.stepId, `STEP_${position}`), stepId: normalizeId(step.stepId, `STEP_${position}`),
sceneId: normalizeId(step.sceneId, `SCENE_${position}`), sceneId: normalizeId(step.sceneId, `SCENE_${position}`),
audioPack: step.audioPack ?? '',
actions: step.actions ?? [],
apps: step.apps ?? [],
transitions: (step.transitions ?? []).map(normalizeTransition),
}; };
}); });
const fallbackStep = const fallbackStep =
normalizedSteps.length > 0 normalizedSteps.length > 0
? normalizedSteps ? normalizedSteps
: [{ stepId: 'STEP_BOOT', sceneId: 'SCENE_BOOT' }]; : [
{
stepId: 'STEP_BOOT',
sceneId: 'SCENE_BOOT',
audioPack: '',
actions: [],
apps: [],
transitions: [],
},
];
const firmwareSteps = fallbackStep.map((step, i) => ({
step_id: step.stepId,
screen_scene_id: step.sceneId,
audio_pack_id: step.audioPack,
actions: step.actions,
apps: step.apps,
transitions: step.transitions.length
? step.transitions.map((transition) => ({
event_type: transition.eventType,
event_name: transition.eventName,
target_step_id: transition.targetStepId,
priority: transition.priority,
after_ms: transition.afterMs,
}))
: i < fallbackStep.length - 1
? [
{
event_type: 'serial',
event_name: 'BTN_NEXT',
target_step_id: fallbackStep[i + 1].stepId,
priority: 0,
after_ms: 0,
},
]
: [],
}));
return { return {
id: normalizeId(scenarioId, 'ZACUS_V2_NEW'), id: normalizeId(scenarioId, 'ZACUS_V2_NEW'),
version: 1, version: 1,
title: 'Nouveau scenario Zacus', title: 'Nouveau scenario Zacus',
players: { players: { min: 6, max: 14 },
min: 6, duration: { total_minutes: 105 },
max: 14,
},
duration: {
total_minutes: 90,
},
canon: { canon: {
introduction: 'Scenario genere depuis frontend scratch-like.', introduction: 'Scenario genere depuis frontend scratch-like.',
stakes: 'Valider les transitions et deployer via API Story V2.', stakes: 'Valider les transitions et deployer via API Story V2.',
}, },
stations: [], stations: [],
puzzles: [], puzzles: [],
steps_reference_order: fallbackStep.map((step) => step.stepId), steps_narrative: fallbackStep.map((s) => ({
steps_narrative: fallbackStep.map((step) => ({ step_id: s.stepId,
step_id: step.stepId, scene: s.sceneId,
scene: step.sceneId, narrative: `Etape ${s.stepId} en scene ${s.sceneId}.`,
narrative: `Etape ${step.stepId} en scene ${step.sceneId}.`,
})), })),
firmware: {
initial_step: fallbackStep[0].stepId,
steps_reference_order: fallbackStep.map((step) => step.stepId),
steps: firmwareSteps,
},
}; };
} }
@@ -96,9 +181,38 @@ export function validateScenarioDocument(
return { ok: false, error: 'players.min must be <= players.max' }; return { ok: false, error: 'players.min must be <= players.max' };
} }
const uniqueStepIds = new Set(parsed.data.steps_reference_order); const stepOrder = resolveScenarioStepOrder(parsed.data);
if (uniqueStepIds.size !== parsed.data.steps_reference_order.length) { const uniqueStepIds = new Set(stepOrder);
return { ok: false, error: 'duplicate step ids in steps_reference_order' }; if (uniqueStepIds.size !== stepOrder.length) {
return { ok: false, error: 'duplicate step ids in scenario step order' };
}
if (parsed.data.firmware) {
const runtimeStepIds = new Set(parsed.data.firmware.steps.map((step) => step.step_id));
if (!runtimeStepIds.has(parsed.data.firmware.initial_step)) {
return { ok: false, error: 'firmware.initial_step must exist in firmware.steps' };
}
for (const step of parsed.data.firmware.steps) {
for (const transition of step.transitions) {
if (!runtimeStepIds.has(transition.target_step_id)) {
return {
ok: false,
error: `transition target missing: ${transition.target_step_id}`,
};
}
}
}
if (
Array.isArray(parsed.data.firmware.steps_reference_order) &&
parsed.data.firmware.steps_reference_order.length > 0 &&
parsed.data.firmware.steps_reference_order.join('|') !==
parsed.data.firmware.steps.map((step) => step.step_id).join('|')
) {
return {
ok: false,
error: 'firmware.steps_reference_order must match firmware.steps order when both are present',
};
}
} }
return { ok: true }; return { ok: true };
@@ -107,3 +221,74 @@ export function validateScenarioDocument(
export function scenarioToYaml(document: ScenarioDocument): string { export function scenarioToYaml(document: ScenarioDocument): string {
return YAML.stringify(document); return YAML.stringify(document);
} }
/** Parse a YAML string back into steps for the Blockly designer */
export function parseYamlToSteps(
yamlStr: string,
): { id: string; steps: ScenarioStep[] } | { error: string } {
try {
const data = YAML.parse(yamlStr);
if (!data || typeof data !== 'object') {
return { error: 'Invalid YAML: not an object' };
}
const id = data.id ?? 'UNKNOWN';
const steps: ScenarioStep[] = [];
const normalizeTransitionList = (items: unknown): StepTransition[] => {
if (!Array.isArray(items)) return [];
return items.map((transition) => ({
eventType:
transition?.event_type ??
transition?.eventType ??
transition?.trigger ??
'serial',
eventName: transition?.event_name ?? transition?.eventName ?? 'BTN_NEXT',
targetStepId:
transition?.target_step_id ?? transition?.targetStepId ?? transition?.target ?? '',
priority: Number(transition?.priority ?? 0),
afterMs: Number(transition?.after_ms ?? transition?.afterMs ?? 0),
}));
};
const fwSteps = data.runtime3?.steps ?? data.firmware?.steps ?? data.steps;
if (Array.isArray(fwSteps)) {
for (const s of fwSteps) {
steps.push({
stepId: s.id ?? s.step_id ?? s.stepId ?? '',
sceneId: s.scene_id ?? s.screen_scene_id ?? s.sceneId ?? s.scene ?? '',
audioPack: s.audio_pack_id ?? '',
actions: s.actions ?? [],
apps: s.apps ?? [],
transitions: normalizeTransitionList(s.transitions),
});
}
}
// Fallback: steps_narrative
if (steps.length === 0 && Array.isArray(data.steps_narrative)) {
for (const s of data.steps_narrative) {
steps.push({
stepId: s.step_id ?? '',
sceneId: s.scene ?? '',
transitions: [],
});
}
}
// Fallback: firmware.steps_reference_order
if (steps.length === 0 && Array.isArray(data.firmware?.steps_reference_order)) {
for (const id of data.firmware.steps_reference_order) {
steps.push({ stepId: id, sceneId: id, transitions: [] });
}
}
if (steps.length === 0) {
return { error: 'No steps found in YAML' };
}
return { id, steps };
} catch (err) {
return { error: `YAML parse error: ${err instanceof Error ? err.message : err}` };
}
}
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type LegacyStatus,
type StoryStatus,
type StreamMessage,
connectStoryStream,
legacyStatus,
storyStatus,
} from './api';
export interface RuntimeState {
connected: boolean;
story: StoryStatus | null;
legacy: LegacyStatus | null;
lastError: string;
lastEvent: StreamMessage | null;
}
const POLL_INTERVAL = 3000;
export function useRuntimeStore() {
const [state, setState] = useState<RuntimeState>({
connected: false,
story: null,
legacy: null,
lastError: '',
lastEvent: null,
});
const wsRef = useRef<{ close: () => void } | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const poll = useCallback(async () => {
try {
const [story, legacy] = await Promise.all([
storyStatus().catch(() => null),
legacyStatus().catch(() => null),
]);
setState((prev) => ({
...prev,
connected: !!(story || legacy),
story: story ?? prev.story,
legacy: legacy ?? prev.legacy,
lastError: '',
}));
} catch (err) {
setState((prev) => ({
...prev,
connected: false,
lastError: err instanceof Error ? err.message : String(err),
}));
}
}, []);
useEffect(() => {
poll();
timerRef.current = setInterval(poll, POLL_INTERVAL);
try {
const ws = connectStoryStream(
(msg) => {
setState((prev) => ({ ...prev, lastEvent: msg }));
if (msg.type === 'step_change' || msg.type === 'status') {
poll();
}
},
() => {
/* ws error — polling will keep us alive */
},
);
wsRef.current = ws;
} catch {
/* no ws support */
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (wsRef.current) wsRef.current.close();
};
}, [poll]);
return { ...state, refresh: poll };
}
/** Detect if we're on the Media Manager screen */
export function isMediaManagerActive(legacy: LegacyStatus | null): boolean {
if (!legacy) return false;
const screen = legacy.story.screen ?? '';
const step = legacy.story.step ?? '';
return (
screen === 'SCENE_MEDIA_MANAGER' ||
step === 'STEP_MEDIA_MANAGER' ||
screen.includes('MEDIA_MANAGER') ||
step.includes('MEDIA_MANAGER')
);
}
+38 -1
View File
@@ -1,6 +1,25 @@
export interface ScenarioStep { export interface ScenarioStep {
stepId: string; stepId: string;
sceneId: string; sceneId: string;
audioPack?: string;
actions?: string[];
apps?: string[];
transitions?: StepTransition[];
}
export interface StepTransition {
eventType:
| 'button'
| 'serial'
| 'timer'
| 'audio_done'
| 'unlock'
| 'espnow'
| 'action';
eventName: string;
targetStepId: string;
priority: number;
afterMs: number;
} }
export interface ScenarioDocument { export interface ScenarioDocument {
@@ -20,10 +39,28 @@ export interface ScenarioDocument {
}; };
stations: Array<Record<string, unknown>>; stations: Array<Record<string, unknown>>;
puzzles: Array<Record<string, unknown>>; puzzles: Array<Record<string, unknown>>;
steps_reference_order: string[];
steps_narrative: Array<{ steps_narrative: Array<{
step_id: string; step_id: string;
scene: string; scene: string;
narrative: string; narrative: string;
}>; }>;
firmware?: {
initial_step?: string;
steps_reference_order?: string[];
steps?: Array<{
step_id: string;
screen_scene_id: string;
audio_pack_id: string;
actions: string[];
apps: string[];
transitions: Array<{
trigger?: string;
event_type: string;
event_name: string;
target_step_id: string;
priority: number;
after_ms: number;
}>;
}>;
};
} }
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import YAML from 'yaml';
import { buildScenarioFromBlocks, parseYamlToSteps, validateScenarioDocument } from '../src/lib/scenario';
import { compileScenarioDocumentToRuntime3, validateRuntime3Document } from '../src/lib/runtime3';
import type { ScenarioDocument } from '../src/types';
function buildGraphFirstDocument(): ScenarioDocument {
return {
id: 'zacus_graph_first',
version: 3,
title: 'Zacus Graph First',
players: { min: 6, max: 12 },
duration: { total_minutes: 90 },
canon: {
introduction: 'Intro',
stakes: 'Stakes',
},
stations: [],
puzzles: [],
steps_narrative: [
{ step_id: 'STEP_BOOT', scene: 'SCENE_BOOT', narrative: 'Boot' },
{ step_id: 'STEP_GATE', scene: 'SCENE_GATE', narrative: 'Gate' },
{ step_id: 'STEP_DONE', scene: 'SCENE_DONE', narrative: 'Done' },
],
firmware: {
initial_step: 'STEP_BOOT',
steps: [
{
step_id: 'STEP_BOOT',
screen_scene_id: 'SCENE_BOOT',
audio_pack_id: '',
actions: [],
apps: [],
transitions: [
{
event_type: 'serial',
event_name: 'BTN_NEXT',
target_step_id: 'STEP_GATE',
priority: 100,
after_ms: 0,
},
],
},
{
step_id: 'STEP_GATE',
screen_scene_id: 'SCENE_GATE',
audio_pack_id: '',
actions: [],
apps: [],
transitions: [
{
event_type: 'unlock',
event_name: 'UNLOCK_GATE',
target_step_id: 'STEP_DONE',
priority: 120,
after_ms: 0,
},
],
},
{
step_id: 'STEP_DONE',
screen_scene_id: 'SCENE_DONE',
audio_pack_id: '',
actions: [],
apps: [],
transitions: [],
},
],
},
};
}
describe('graph-first scenario handling', () => {
it('validates a canonical graph-first document without steps_reference_order', () => {
const document = buildGraphFirstDocument();
expect(validateScenarioDocument(document)).toEqual({ ok: true });
});
it('parses firmware.steps from YAML before older fallback fields', () => {
const yaml = YAML.stringify(buildGraphFirstDocument());
const parsed = parseYamlToSteps(yaml);
if ('error' in parsed) {
throw new Error(parsed.error);
}
expect(parsed.steps.map((step) => step.stepId)).toEqual([
'STEP_BOOT',
'STEP_GATE',
'STEP_DONE',
]);
expect(parsed.steps[1]?.transitions?.[0]?.eventType).toBe('unlock');
});
it('compiles runtime3 entry step from firmware.initial_step', () => {
const runtime3 = compileScenarioDocumentToRuntime3(buildGraphFirstDocument());
expect(runtime3.scenario.entry_step_id).toBe('STEP_BOOT');
expect(runtime3.steps[1]?.transitions[0]?.event_type).toBe('unlock');
expect(validateRuntime3Document(runtime3)).toEqual({ ok: true });
});
it('keeps the builder output compatible with the graph-first validator', () => {
const built = buildScenarioFromBlocks('zacus_builder', [
{
stepId: 'STEP_BOOT',
sceneId: 'SCENE_BOOT',
transitions: [
{
eventType: 'serial',
eventName: 'BTN_NEXT',
targetStepId: 'STEP_DONE',
priority: 50,
afterMs: 0,
},
],
},
{
stepId: 'STEP_DONE',
sceneId: 'SCENE_DONE',
transitions: [],
},
]);
expect(validateScenarioDocument(built)).toEqual({ ok: true });
expect(built.firmware?.steps?.[0]?.step_id).toBe('STEP_BOOT');
expect(built.firmware?.steps_reference_order).toEqual(['STEP_BOOT', 'STEP_DONE']);
expect('steps_reference_order' in built).toBe(false);
});
});
+105 -11
View File
@@ -47,17 +47,111 @@ firmware:
- "SCENE_MP3_PLAYER" - "SCENE_MP3_PLAYER"
- "SCENE_READY" - "SCENE_READY"
steps_reference_order: initial_step: STEP_U_SON_PROTO
- STEP_U_SON_PROTO
- STEP_LA_DETECTOR
- STEP_RTC_ESP_ETAPE1
- STEP_WIN_ETAPE1
- STEP_WARNING
- STEP_LEFOU_DETECTOR
- STEP_RTC_ESP_ETAPE2
- STEP_QR_DETECTOR
- STEP_FINAL_WIN
steps:
- step_id: STEP_U_SON_PROTO
screen_scene_id: SCENE_U_SON_PROTO
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_LA_DETECTOR
priority: 100
after_ms: 0
- step_id: STEP_LA_DETECTOR
screen_scene_id: SCENE_LA_DETECTOR
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: unlock
event_name: UNLOCK_LA_440
target_step_id: STEP_RTC_ESP_ETAPE1
priority: 120
after_ms: 0
- step_id: STEP_RTC_ESP_ETAPE1
screen_scene_id: SCENE_WIN_ETAPE1
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: espnow
event_name: ACK_WIN1
target_step_id: STEP_WIN_ETAPE1
priority: 130
after_ms: 0
- step_id: STEP_WIN_ETAPE1
screen_scene_id: SCENE_WIN_ETAPE1
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_WARNING
priority: 110
after_ms: 0
- step_id: STEP_WARNING
screen_scene_id: SCENE_WARNING
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_LEFOU_DETECTOR
priority: 100
after_ms: 0
- step_id: STEP_LEFOU_DETECTOR
screen_scene_id: SCENE_LEFOU_DETECTOR
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: unlock
event_name: UNLOCK_PIANO_LEFOU
target_step_id: STEP_RTC_ESP_ETAPE2
priority: 120
after_ms: 0
- step_id: STEP_RTC_ESP_ETAPE2
screen_scene_id: SCENE_WIN_ETAPE2
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: espnow
event_name: ACK_WIN2
target_step_id: STEP_QR_DETECTOR
priority: 130
after_ms: 0
- step_id: STEP_QR_DETECTOR
screen_scene_id: SCENE_QR_DETECTOR
audio_pack_id: ""
actions: []
apps: []
transitions:
- event_type: unlock
event_name: UNLOCK_QR
target_step_id: STEP_FINAL_WIN
priority: 140
after_ms: 0
- step_id: STEP_FINAL_WIN
screen_scene_id: SCENE_FINAL_WIN
audio_pack_id: ""
actions: []
apps: []
transitions: []
# ============================================================ # ============================================================
# CANON NARRATIF (réalité terrain) # CANON NARRATIF (réalité terrain)
# ============================================================ # ============================================================
@@ -390,4 +484,4 @@ props_and_assets:
notes: > notes: >
Cette version est 100% alignée avec la réalité “2 étapes + QR + Media Hub”. Cette version est 100% alignée avec la réalité “2 étapes + QR + Media Hub”.
Si tu veux, je peux aussi te générer la fiche MJ (indices soft/hard, cadence, placements) Si tu veux, je peux aussi te générer la fiche MJ (indices soft/hard, cadence, placements)
et une version “short 90 min” sans casser le runtime. et une version “short 90 min” sans casser le runtime.
@@ -0,0 +1,161 @@
{
"schema_version": "zacus.runtime3.v1",
"scenario": {
"id": "ZACUS_V2",
"version": 3,
"title": "Le mystère du Professeur Zacus — Version Réelle (U-SON, piano LEFOU, QR WIN)",
"entry_step_id": "STEP_U_SON_PROTO",
"source_kind": "yaml"
},
"steps": [
{
"id": "STEP_U_SON_PROTO",
"scene_id": "SCENE_U_SON_PROTO",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "STEP_LA_DETECTOR",
"priority": 100,
"after_ms": 0
}
],
"narrative": "Boot radio. Transmission tronquée. Zacus annonce que U-SON dérive et quil faut une référence stable pour recaler le système.\n"
},
{
"id": "STEP_LA_DETECTOR",
"scene_id": "SCENE_LA_DETECTOR",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "unlock",
"event_name": "UNLOCK_LA_440",
"target_step_id": "STEP_RTC_ESP_ETAPE1",
"priority": 120,
"after_ms": 0
}
],
"narrative": "Atelier des Ondes. Objectif : produire un LA 440 stable. Le groupe sorganise : une personne fait le son, dautres surveillent le feedback.\n"
},
{
"id": "STEP_RTC_ESP_ETAPE1",
"scene_id": "SCENE_WIN_ETAPE1",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "espnow",
"event_name": "ACK_WIN1",
"target_step_id": "STEP_WIN_ETAPE1",
"priority": 130,
"after_ms": 0
}
],
"narrative": "Transmission distante (ESP-NOW). LACK confirme la validation Étape 1.\n"
},
{
"id": "STEP_WIN_ETAPE1",
"scene_id": "SCENE_WIN_ETAPE1",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "audio_done",
"event_name": "AUDIO_DONE",
"target_step_id": "STEP_WARNING",
"priority": 110,
"after_ms": 0
}
],
"narrative": "Victoire 1. Puis le campus bascule en WARNING : direction Zone 4.\n"
},
{
"id": "STEP_WARNING",
"scene_id": "SCENE_WARNING",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "STEP_LEFOU_DETECTOR",
"priority": 100,
"after_ms": 0
}
],
"narrative": "Alerte rouge. Message clair : Zone 4, linstallation de l’Électron Fou. Le système annonce quun code musical (5 lettres) sera requis.\n"
},
{
"id": "STEP_LEFOU_DETECTOR",
"scene_id": "SCENE_LEFOU_DETECTOR",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "unlock",
"event_name": "UNLOCK_PIANO_LEFOU",
"target_step_id": "STEP_RTC_ESP_ETAPE2",
"priority": 120,
"after_ms": 0
}
],
"narrative": "Zone 4. Piano-alphabet : les lettres sont sur les touches blanches. L’équipe doit jouer LEFOU (L-E-F-O-U) pour prouver quelle a compris la règle.\n"
},
{
"id": "STEP_RTC_ESP_ETAPE2",
"scene_id": "SCENE_WIN_ETAPE2",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "espnow",
"event_name": "ACK_WIN2",
"target_step_id": "STEP_QR_DETECTOR",
"priority": 130,
"after_ms": 0
}
],
"narrative": "Confirmation Étape 2. Le système donne la destination finale : les Archives, et révèle que la clé est derrière le portrait.\n"
},
{
"id": "STEP_QR_DETECTOR",
"scene_id": "SCENE_QR_DETECTOR",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [
{
"event_type": "unlock",
"event_name": "UNLOCK_QR",
"target_step_id": "STEP_FINAL_WIN",
"priority": 140,
"after_ms": 0
}
],
"narrative": "Aux Archives : chercher derrière le portrait, récupérer le QR, et le scanner. QR “WIN” valide laccès final.\n"
},
{
"id": "STEP_FINAL_WIN",
"scene_id": "SCENE_FINAL_WIN",
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": [],
"narrative": "Victoire + révélation. Récompense : bascule vers le Media Hub, et boot persistant après QR validé.\n"
}
],
"metadata": {
"migration_mode": "firmware_import",
"generated_by": "tools/scenario/compile_runtime3.py"
}
}
+41
View File
@@ -0,0 +1,41 @@
site_name: Zacus V3
site_description: Zacus refactor architecture, runtime, studio, and operating guides.
site_url: https://example.invalid/zacus
repo_name: electron-rare/le-mystere-professeur-zacus
docs_dir: docs
exclude_docs: |
README.md
AGENTS*.md
README_FAQ_SNIPPET.md
design.md
theme:
name: material
features:
- navigation.sections
- navigation.expand
- content.code.copy
markdown_extensions:
- admonition
- attr_list
- md_in_html
- toc:
permalink: true
plugins:
- search
nav:
- Home: index.md
- Quickstart: QUICKSTART.md
- Architecture:
- Overview: architecture/index.md
- System Map: architecture/system-map.md
- Component Map: architecture/component-map.md
- Data Flow Map: architecture/data-flow-map.md
- Feature Map: architecture/feature-map.md
- Migration Map: architecture/migration-map.md
- Agent Matrix: architecture/agent-matrix.md
- Release Map: architecture/release-map.md
- AI Integration Map: architecture/ai-integration-map.md
- AI Integration Analysis: AI_INTEGRATION_ANALYSIS.md
- Security: SECURITY.md
- Deployment Runbook: DEPLOYMENT_RUNBOOK.md
- OSS Benchmark: benchmark-oss.md
+30
View File
@@ -0,0 +1,30 @@
# Sprint Plan: AI Integration — Zacus
## Sprint 1 (Week 1-2): Security + Voice Foundation
- [ ] Deploy auth middleware to main.cpp (integrate security/ module)
- [ ] Order INMP441 microphone module
- [ ] Deploy Piper TTS Docker on VM (docker-compose.tts.yml)
- [ ] Test Piper TTS French voice quality
- [ ] Contact Espressif for "Professeur Zacus" wake word training
- [ ] Set up ESP-IDF dev environment alongside Arduino
## Sprint 2 (Week 2-3): Voice Pipeline Alpha
- [ ] Integrate ESP-SR WakeNet with placeholder wake word
- [ ] Implement WebSocket audio streaming (ESP32 → mascarade)
- [ ] Create mascarade voice bridge endpoint
- [ ] Test mic → server → TTS → speaker round-trip
- [ ] Measure end-to-end latency (target: <2s)
## Sprint 3 (Week 3-4): Vision + Hints
- [ ] Deploy ESP-DL v3.2 on ESP32-S3
- [ ] Train custom object detection model (3 puzzle props)
- [ ] Implement LLM hint endpoint on mascarade
- [ ] Create prompt library for Professor Zacus NPC
- [ ] Integrate analytics event tracking
## Sprint 4 (Week 4): Integration + Polish
- [ ] Deploy XTTS-v2 on KXKM-AI for voice cloning
- [ ] Record Professor Zacus 10s voice sample
- [ ] Full voice pipeline with cloned voice
- [ ] AudioCraft ambient generation test
- [ ] End-to-end game playtest with AI features
+13
View File
@@ -0,0 +1,13 @@
# Agent Content / Narrative
## Scope
- Scenario canon, puzzle mapping, printables/audio coherence, and GM pack continuity.
## Responsibilities
- Preserve `game/scenarios/zacus_v2.yaml` as the narrative source during migration.
- Formalize acts, stations, puzzles, and runtime pivots as feature maps.
- Track `v1` references and move them toward archive-only status.
## Current Tasks
- Map `LA 440`, `LEFOU`, and `QR WIN` into Runtime 3 acceptance fixtures.
- Keep printables/audio references aligned with the canonical scenario.

Some files were not shown because too many files have changed in this diff Show More