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>
@@ -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.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
- 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`.
|
||||
|
||||
## References
|
||||
- `docs/AGENT_TODO.md`
|
||||
- `hardware/firmware/docs/AGENT_TODO.md`
|
||||
- `docs/TEST_SCRIPT_COORDINATOR.md`
|
||||
- `docs/AGENTS_INDEX.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: git status -sb
|
||||
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.
|
||||
- run: cat GIT_WRITE_OPS_FINAL_REPORT.md
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| `kit.md` | GM kit station/export, lien jeu/printables, pas de renommage sans synchro. |
|
||||
| `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_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_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. |
|
||||
| `PHASE_LAUNCH_PLAN.md` | Checkpoint de lancement de phase : gates, artefacts, reporting RC/AGENT_TODO. |
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ Execution plan for gating, artifacts, and reporting required to open a new phase
|
||||
## 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`.
|
||||
- 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`.
|
||||
- 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
|
||||
- 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
|
||||
- `docs/TEST_SCRIPT_COORDINATOR.md`
|
||||
- `docs/AGENT_TODO.md`
|
||||
- `hardware/firmware/docs/AGENT_TODO.md`
|
||||
- `docs/RC_FINAL_REPORT_TEMPLATE.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.
|
||||
- run: python3 tools/test/audit_coherence.py
|
||||
- run: cat GIT_WRITE_OPS_FINAL_REPORT.md
|
||||
|
||||
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4,8 +4,8 @@
|
||||
`audio/manifests/**` and derived `audio/generated/**` outputs.
|
||||
|
||||
## Do
|
||||
- Run `python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml` after edits.
|
||||
- Refresh exports with `python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml` when manifest references change.
|
||||
- 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_v2.yaml` when manifest references change.
|
||||
|
||||
## Must Not
|
||||
- Regenerate binary audio assets unless explicitly requested.
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
## Plan d’action
|
||||
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/scenario/export_md.py game/scenarios/zacus_v1.yaml
|
||||
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
|
||||
- run: python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ All files under `hardware/firmware/**`.
|
||||
## 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.
|
||||
- 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
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- Keep scripts non-interactive when possible and surface a short, grep-friendly summary.
|
||||
|
||||
## 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.
|
||||
|
||||
## References
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Do
|
||||
- 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.
|
||||
|
||||
## Must Not
|
||||
@@ -17,9 +17,9 @@
|
||||
|
||||
## Plan d’action
|
||||
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/export_md.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_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.
|
||||
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml
|
||||
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml
|
||||
|
||||
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
|
||||
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Gates
|
||||
- PlatformIO matrix (`pio run -e esp32dev`, `esp32_release`, `esp8266_oled`, `ui_rp2040_ili9488`, `ui_rp2040_ili9486`).
|
||||
- Scenario/audio/printables validators from `docs/AGENTS_INDEX.md`.
|
||||
- PlatformIO release gates (`pio run -d hardware/firmware -e freenove_esp32s3`, `pio run -d hardware/firmware -e esp8266_oled`).
|
||||
- Scenario/audio/printables validators plus Runtime 3 checks from `Makefile` and `tools/test/run_content_checks.sh`.
|
||||
|
||||
## References
|
||||
- `AGENTS.md`
|
||||
@@ -24,13 +24,11 @@ Entire repository; project manager + tech lead + QA gatekeeper as per `AGENTS.md
|
||||
- run: git status -sb
|
||||
- run: git diff --stat
|
||||
2. Exécuter la matrice PlatformIO complète via PlatformIO.
|
||||
- run: pio run -e esp32dev
|
||||
- run: pio run -e esp32_release
|
||||
- run: pio run -e esp8266_oled
|
||||
- run: pio run -e ui_rp2040_ili9488
|
||||
- run: pio run -e ui_rp2040_ili9486
|
||||
- run: pio run -d hardware/firmware -e freenove_esp32s3
|
||||
- run: pio run -d hardware/firmware -e esp8266_oled
|
||||
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/export_md.py game/scenarios/zacus_v1.yaml
|
||||
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml
|
||||
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml
|
||||
- run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
|
||||
- run: python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
|
||||
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Do
|
||||
- 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
|
||||
- Rename station identifiers without syncing references across game and printables.
|
||||
@@ -19,5 +19,4 @@
|
||||
- run: rg --files kit-maitre-du-jeu
|
||||
- run: rg -n 'station|indice|enigme|zacus' kit-maitre-du-jeu
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
`printables/manifests/**` and `printables/src/**`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Must Not
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
## Plan d’action
|
||||
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).
|
||||
- run: rg -n 'file:' printables/manifests/zacus_v1_printables.yaml
|
||||
|
||||
- run: rg -n 'file:' printables/manifests/zacus_v2_printables.yaml
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [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`.
|
||||
- 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.
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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:
|
||||
$(PYTHON) tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
|
||||
$(PYTHON) tools/scenario/validate_scenario.py $(SCENARIO)
|
||||
|
||||
audio-validate:
|
||||
$(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
|
||||
|
||||
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."
|
||||
|
||||
images:
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
## 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 l’enquê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 l’imprimes en 3D).
|
||||
>
|
||||
> **⚛ L’électron rare — ⚡ unstable by design**
|
||||
>
|
||||
> **Auteur : Clément SAILLANT**
|
||||
## Démarrage rapide
|
||||
|
||||
[](./.github/workflows/validate.yml)
|
||||
[](https://platformio.org/)
|
||||
[](./LICENSE)
|
||||
[](./LICENSE-CONTENT.md)
|
||||
|
||||
|
||||
## ⚡ Pitch (30 secondes chrono)
|
||||
|
||||
Le Professeur Zacus a disparu. Son labo est sous tension : **signaux audio**, **capsules d’indices**, **preuves imprimées**… et un dispositif électronique indispensable qui réagit à chaque étape de l’enquête.
|
||||
|
||||
Les joueurs fouillent, recoupent, déduisent — comme une vraie équipe d’enquê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, c’est normal. Si elle te parle, c’est probablement un bug... ou le MJ qui s’ennuie."*
|
||||
---
|
||||
|
||||
## ✅ 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 n’ont 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** : 6–14 (recommandé), ou équipes de 2–4
|
||||
- **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
|
||||
|
||||

|
||||
|
||||
> *"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/`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🧩 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 l’idée est marrante.)"*
|
||||
|
||||
1. Lis le **guide MJ** : `kit-maitre-du-jeu/`
|
||||
2. Imprime les **printables** : `printables/`
|
||||
3. Prépare l’audio : `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 :
|
||||
### 1. Bootstrap validation
|
||||
```bash
|
||||
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
|
||||
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 :
|
||||
```bash
|
||||
python3 tools/scenario/export_md.py game/scenarios/zacus_v2.yaml -o docs/exports/zacus_v2.md
|
||||
```
|
||||
|
||||
Frontend canon (V2) :
|
||||
### 3. Démarrer le studio React + Blockly
|
||||
```bash
|
||||
cd frontend-scratch-v2
|
||||
npm install
|
||||
npm test
|
||||
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧾 Contenu du dépôt (repères galactiques)
|
||||
|
||||
> *"Ce dépôt est plus organisé que le bureau du Professeur Zacus (et ça, c’est pas peu dire)."*
|
||||
|
||||
```text
|
||||
game/scenarios/ Scénarios YAML (source de vérité)
|
||||
kit-maitre-du-jeu/ Guide MJ + solutions + script
|
||||
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
|
||||
### 4. Utiliser le shell canonique
|
||||
```bash
|
||||
./tools/dev/zacus.sh content-checks
|
||||
./tools/dev/zacus.sh runtime3-compile
|
||||
make runtime3-verify
|
||||
make runtime3-test
|
||||
./tools/dev/zacus.sh frontend-test
|
||||
./tools/dev/zacus.sh frontend-build
|
||||
./tools/dev/zacus.sh menu
|
||||
```
|
||||
|
||||
---
|
||||
## 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`
|
||||
|
||||

|
||||
Analyse complète : [`docs/AI_INTEGRATION_ANALYSIS.md`](docs/AI_INTEGRATION_ANALYSIS.md)
|
||||
|
||||

|
||||
## 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`)
|
||||
- **Contenu créatif** (scénarios, docs, printables, assets) : CC BY‑NC 4.0 (`LICENSE-CONTENT.md`)
|
||||
- Runtime 3 : compilateur + simulateur + export firmware bundle OK
|
||||
- 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
|
||||
|
||||
> *"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."*
|
||||
|
||||
**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
|
||||
## Licences
|
||||
- Code: MIT (`LICENSE`)
|
||||
- Contenu créatif: CC BY-NC 4.0 (`LICENSE-CONTENT.md`)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboarding / briefing files (`.github/agents/`, `docs/_generated/`).
|
||||
|
||||
## 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 d’organisation 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`.
|
||||
- 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
|
||||
- Les docs n’intègrent pas d’artefacts 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
|
||||
- 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.
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
Firmware agents cover `esp32_audio/`, `ui/esp8266_oled/`, `ui/rp2040_tft/`, and shared `protocol/` code that drives the MCU binaries.
|
||||
|
||||
## Doit
|
||||
- Lire puis mettre à jour `docs/AGENT_TODO.md` avant d’agir (le tracker unique garde la trace de l’état de chaque gate).
|
||||
- Lire puis mettre à jour `hardware/firmware/docs/AGENT_TODO.md` avant d’agir (le tracker unique garde la trace de l’état de chaque gate).
|
||||
- Respecter le contrat global (`AGENTS.md`) et les règles d’outillage de `tools/dev/AGENTS.md` (gates/scripts centralisés, artefacts hors git).
|
||||
- S’assurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `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`.
|
||||
- Toujours signaler l’état des UI Link / LittleFS / I2S dans `docs/AGENT_TODO.md:6-14` lorsqu’on manipule ces composants.
|
||||
- S’assurer 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 `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 `hardware/firmware/docs/AGENT_TODO.md:6-14` lorsqu’on manipule ces composants.
|
||||
|
||||
## Reporting
|
||||
- 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
|
||||
- Branche principale : `docs/SPRINT_RECOMMENDATIONS.md`, `README.md`, `protocol/ui_link_v2.md`
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
Tests agents focus on files under `tools/dev/`, `tools/test/`, `esp32_audio/tests/`, and the artifacts/log directories (`artifacts/`, `logs/`).
|
||||
|
||||
## 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/`.
|
||||
- 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).
|
||||
- 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
|
||||
- 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 d’artefacts ; les chemins doivent être mentionnés dans le TODO.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Depuis ce dossier:
|
||||
- Python 3.x avec PyYAML installé (`pip install pyyaml`)
|
||||
- Le dépôt cloné à la racine du projet
|
||||
|
||||
```bash
|
||||
cd hardware/firmware/esp32_audio
|
||||
## 1) Source canonique du scénario
|
||||
|
||||
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`
|
||||
- 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`
|
||||
Pour éditer le scénario graphiquement, utiliser le studio React + Blockly :
|
||||
|
||||
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`
|
||||
|
||||
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 d’atteindre une fin logique (`DONE` ou équivalent).
|
||||
Le studio permet de manipuler les étapes, transitions et événements visuellement, puis d'exporter vers le format YAML V2.
|
||||
|
||||
## 3) Valider le scénario
|
||||
|
||||
```bash
|
||||
make story-validate
|
||||
make scenario-validate
|
||||
```
|
||||
|
||||
Cette commande vérifie:
|
||||
Cette commande vérifie :
|
||||
|
||||
- la conformité de structure,
|
||||
- les IDs / références,
|
||||
- la conformité de structure au schéma V2,
|
||||
- les IDs / références croisées,
|
||||
- la cohérence des transitions,
|
||||
- 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
|
||||
make story-gen
|
||||
make runtime3-compile
|
||||
```
|
||||
|
||||
Le générateur produit les fichiers utilisés par le runtime V2:
|
||||
|
||||
- `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
|
||||
Cela exécute `tools/scenario/compile_runtime3.py` sur le scénario par défaut. Pour spécifier un fichier :
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Puis upload selon ton port série:
|
||||
|
||||
```bash
|
||||
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_LIST`
|
||||
@@ -97,15 +102,27 @@ Dans le moniteur série, commandes utiles:
|
||||
## Workflow court (copier-coller)
|
||||
|
||||
```bash
|
||||
cd hardware/firmware/esp32_audio
|
||||
make story-validate
|
||||
make story-gen
|
||||
pio run -e esp32dev
|
||||
make scenario-validate
|
||||
make runtime3-compile
|
||||
make runtime3-simulate
|
||||
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
|
||||
|
||||
- Commence simple: 3 à 4 étapes max au début.
|
||||
- N’ajoute qu’un seul nouveau type d’événement à la fois.
|
||||
- Teste chaque transition clé au moniteur série (`STORY_V2_EVENT ...`).
|
||||
- Garde un scénario « de secours » valide dans `docs/protocols/story_specs/scenarios/`.
|
||||
- Commencer simple : 3 à 4 étapes max au début.
|
||||
- N'ajouter qu'un seul nouveau type d'événement à la fois.
|
||||
- Toujours valider (`make scenario-validate`) avant de compiler.
|
||||
- Tester chaque transition clé au moniteur série (`STORY_V2_EVENT ...`).
|
||||
- Utiliser `make runtime3-simulate` pour déboguer sans flasher.
|
||||
|
||||
@@ -1,82 +1,98 @@
|
||||
# Quickstart
|
||||
|
||||
## En un coup d’œil
|
||||
- Source de vérité : `game/scenarios/zacus_v2.yaml` (dérive tout le reste : kit MJ, audio, printables).
|
||||
- Public : 6 à 14 enfants, 105 minutes (45 + 60).
|
||||
- Matériel : `kit-maitre-du-jeu/`, `printables/`, `game/scenarios/zacus_v2.yaml`, `audio/manifests/zacus_v2_audio.yaml`, `hardware/firmware/` (ESP32 + écran tactile).
|
||||
- Licences : contenus créatifs CC BY-NC 4.0 (`LICENSES/CC-BY-NC-4.0.txt`), code/script MIT (`LICENSES/MIT.txt`).
|
||||
|
||||
## 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 l’accueil rapide (`script-minute-par-minute.md`).
|
||||
4. Flash et configure l’ESP32 en suivant `hardware/firmware/README.md` pour que l’interface réagisse aux stations imprimées.
|
||||
|
||||
## Préparer l’électronique
|
||||
|
||||
1. Installe PlatformIO (`pip install -U platformio`) et branche l’ESP32 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 l’ESP32 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 d’enquête (10-80 min) : station par station, fiche d’enquê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:
|
||||
## Vue canonique
|
||||
- Source de vérité: `game/scenarios/zacus_v2.yaml`
|
||||
- Studio auteur: `frontend-scratch-v2/`
|
||||
- Runtime portable: Zacus Runtime 3
|
||||
- Cible terrain principale: `hardware/firmware` sur `freenove_esp32s3`
|
||||
- Shell d'orchestration: `tools/dev/zacus.sh`
|
||||
|
||||
## Valider le contenu
|
||||
```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`
|
||||
Préflight USB ZeroClaw : `./tools/dev/zacus.sh zeroclaw-preflight --require-port`
|
||||
|
||||
Checks contenu (sans hardware):
|
||||
Checks inclus:
|
||||
- validation scénario
|
||||
- compilation Runtime 3
|
||||
- simulation Runtime 3
|
||||
- validation runtime bundle
|
||||
- validation audio
|
||||
- validation printables
|
||||
- export Markdown
|
||||
|
||||
## Compiler le runtime portable
|
||||
```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
|
||||
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
|
||||
python3 tools/test/run_serial_suite.py --suite smoke_plus --allow-no-hardware
|
||||
python3 tools/test/zacus_menu.py --action smoke --allow-no-hardware
|
||||
cd frontend-scratch-v2
|
||||
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:
|
||||
- `pip install pyyaml` pour `run_content_checks.sh`
|
||||
- `pip install pyserial` pour suites USB, UI Link sim et console série
|
||||
## Valider le firmware
|
||||
```bash
|
||||
cd hardware/firmware
|
||||
pio run -e freenove_esp32s3
|
||||
pio run -e esp8266_oled
|
||||
```
|
||||
|
||||
## Pour aller plus loin
|
||||
- Crée une variante : duplique le YAML, modifie `canon` et `solution` et revalide avec le script.
|
||||
- Ajoute des cartes imprimables en t’inspirant des prompts dans `printables/src/prompts/`.
|
||||
- Mets à jour ce quickstart via un PR si le montage change (p. ex. nouvelle station ou nouvelle plage horaire).
|
||||
Chemin mono-carte recommandé:
|
||||
```bash
|
||||
cd hardware/firmware
|
||||
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`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
@@ -1,196 +1,41 @@
|
||||
# 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.
|
||||
|
||||
## Root Structure
|
||||
|
||||
```
|
||||
le-mystere-professeur-zacus/
|
||||
├── .github/ # GitHub configuration (workflows, templates)
|
||||
├── audio/ # Audio manifests and prompts
|
||||
├── docs/ # Documentation
|
||||
├── 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.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
YAML["game/scenarios/*.yaml"] --> Runtime3["tools/scenario Runtime 3"]
|
||||
YAML --> Exports["audio / printables / kit MJ"]
|
||||
Runtime3 --> Studio["frontend-scratch-v2"]
|
||||
Runtime3 --> Firmware["hardware/firmware"]
|
||||
Plans["memory + plans + todos"] --> Studio
|
||||
Plans --> Firmware
|
||||
Docs["docs/architecture + specs"] --> Plans
|
||||
```
|
||||
|
||||
## 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/`
|
||||
GitHub-specific configuration files:
|
||||
- **`ISSUE_TEMPLATE/`**: Issue templates for bugs and features
|
||||
- **`PULL_REQUEST_TEMPLATE.md`**: Pull request template
|
||||
- **`workflows/`**: CI/CD workflows for validation
|
||||
## Règles de structure
|
||||
- `game/scenarios/*.yaml` reste la source de vérité narrative.
|
||||
- `hardware/firmware/esp32/` est en lecture seule.
|
||||
- `frontend-scratch-v2/` est le front canonique; les chemins legacy doivent sortir du flux principal.
|
||||
- Les artefacts d'exécution restent hors git et sont référencés depuis les TODO ou rapports.
|
||||
|
||||
### `audio/`
|
||||
Audio content organization:
|
||||
- **`manifests/`**: YAML manifests describing audio requirements
|
||||
- **`game/prompts/audio/`**: Audio generation prompts (intro, incidents, solutions)
|
||||
|
||||
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`.
|
||||
## Chemins de référence
|
||||
- Architecture: `docs/architecture/index.md`
|
||||
- Runtime: `specs/ZACUS_RUNTIME_3_SPEC.md`
|
||||
- Studio: `specs/STORY_DESIGNER_SCRATCH_LIKE_SPEC.md`
|
||||
- Pilotage: `plans/master-plan.md`, `todos/master.md`
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Zacus Architecture
|
||||
|
||||
Cette section est le point d’entrée canonique de la refonte Zacus V3.
|
||||
|
||||
## Vue d’ensemble
|
||||
- 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 d’exécution et d’API.
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
@@ -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.
|
||||
@@ -1,119 +1,31 @@
|
||||
---
|
||||
layout: default
|
||||
title: Le Mystère du Professeur Zacus
|
||||
description: Une enquête scientifique immersive pour anniversaire (printables, audio, modules électroniques).
|
||||
---
|
||||
# Zacus V3
|
||||
|
||||
# 🎩 Le Mystère du Professeur Zacus
|
||||
Point d'entrée documentaire de la refonte Zacus.
|
||||
|
||||

|
||||
## 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">
|
||||
<div class="badge"><img src="./assets/icons/icon_hat.png" alt="hat" />Scénarios modulaires</div>
|
||||
<div class="badge"><img src="./assets/icons/icon_audio.png" alt="audio" />Audio & checkpoints</div>
|
||||
<div class="badge"><img src="./assets/icons/icon_chip.png" alt="chip" />Modules électroniques</div>
|
||||
<div class="badge"><img src="./assets/icons/icon_flask.png" alt="flask" />Thème labo/science</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```mermaid
|
||||
flowchart LR
|
||||
YAML["Scenario YAML"] --> Runtime3["Runtime 3"]
|
||||
Runtime3 --> Studio["React + Blockly studio"]
|
||||
Runtime3 --> Firmware["Firmware adapter"]
|
||||
YAML --> Exports["Audio / printables / MJ kit"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
1. Imprime les printables (cartes, indices).
|
||||
2. (Option) Flash les modules électroniques.
|
||||
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 BY‑NC 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
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Licences
|
||||
- **Code** : MIT (`LICENSE`)
|
||||
- **Contenu** : CC BY‑NC 4.0 (`LICENSE-CONTENT.md`)
|
||||
## Liens utiles
|
||||
- [Repository Structure](STRUCTURE.md)
|
||||
- Spécification Runtime 3: `specs/ZACUS_RUNTIME_3_SPEC.md`
|
||||
- Spécification studio: `specs/STORY_DESIGNER_SCRATCH_LIKE_SPEC.md`
|
||||
- Plans: `plans/master-plan.md`
|
||||
- Todos: `todos/master.md`
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 s’ils restent ou disparaissent lors d’un 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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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 l’auteur 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).
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 l’implé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 d’une 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 d’un 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 l’app 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 d’acceptation 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`.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
passWithNoTests: false,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
generer du YAML, puis appeler les endpoints Story V2.
|
||||
Studio auteur visuel pour concevoir des scenarios Zacus avec des blocs type Scratch.
|
||||
Genere du YAML canonique et communique avec le firmware ESP32 via l'API Story V2.
|
||||
|
||||
## Objectif
|
||||
|
||||
- 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
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd frontend-scratch-v2
|
||||
@@ -31,32 +11,66 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Option API:
|
||||
Connexion API (optionnel) :
|
||||
|
||||
```bash
|
||||
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
|
||||
```
|
||||
|
||||
## Fonctionnalites livrees
|
||||
## Scripts disponibles
|
||||
|
||||
- Workspace Blockly avec bloc `step`.
|
||||
- Generation YAML live depuis les blocs.
|
||||
- Preview/edit YAML dans Monaco.
|
||||
- Actions runtime:
|
||||
- GET `/api/story/list`
|
||||
- GET `/api/story/status`
|
||||
- POST `/api/story/validate`
|
||||
- POST `/api/story/deploy`
|
||||
| Commande | Description |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Serveur de dev Vite (HMR) |
|
||||
| `npm run build` | Build production (`dist/`) |
|
||||
| `npm test` | Tests Vitest (18 tests) |
|
||||
| `npm run lint` | ESLint |
|
||||
|
||||
## Structure
|
||||
## Architecture
|
||||
|
||||
- `src/components/BlocklyDesigner.tsx`: editeur blocs + generation YAML.
|
||||
- `src/components/RuntimeControls.tsx`: actions HTTP Story V2.
|
||||
- `src/lib/scenario.ts`: mapping blocs -> document scenario -> YAML.
|
||||
- `src/types.ts`: types du document scenario.
|
||||
L'interface s'organise en 4 onglets :
|
||||
|
||||
## 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.
|
||||
2. Ajouter import/export Blockly JSON.
|
||||
3. Ajouter tests unitaires de generation YAML et smoke E2E API mock.
|
||||
Autres fichiers cles :
|
||||
- `src/components/RuntimeControls.tsx` — actions HTTP Story V2 (list, status, validate, deploy)
|
||||
- `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
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -1592,6 +1593,24 @@
|
||||
"@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": {
|
||||
"version": "1.0.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2031,6 +2165,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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_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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -2139,6 +2293,23 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2156,6 +2327,16 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2261,6 +2442,16 @@
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -2560,6 +2758,16 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -2570,6 +2778,16 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3028,6 +3246,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3038,6 +3263,16 @@
|
||||
"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": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
@@ -3215,6 +3450,23 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3441,6 +3693,13 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3451,12 +3710,26 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "7.2.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -3506,6 +3813,36 @@
|
||||
"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": {
|
||||
"version": "6.1.86",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@@ -3807,6 +4240,23 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -30,6 +31,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
margin: 0.25rem 0 0;
|
||||
opacity: 0.82;
|
||||
.header-status {
|
||||
display: flex;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.app-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 300px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ─── Blockly ─── */
|
||||
.blockly-host {
|
||||
height: 48vh;
|
||||
height: 50vh;
|
||||
min-height: 340px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--green);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-line.error {
|
||||
color: #b42318;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ─── Runtime panel ─── */
|
||||
.runtime-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-panel .actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.runtime-panel pre {
|
||||
margin: 0;
|
||||
min-height: 120px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 0.5rem;
|
||||
background: #f6f8fa;
|
||||
padding: 0.6rem;
|
||||
overflow: auto;
|
||||
font-size: 0.8rem;
|
||||
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;
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.runtime-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* ─── Rec form ─── */
|
||||
.rec-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { BlocklyDesigner } from './components/BlocklyDesigner';
|
||||
import { Component, Suspense, lazy, useState } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { RuntimeControls } from './components/RuntimeControls';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { useRuntimeStore } from './lib/useRuntimeStore';
|
||||
import { setApiBase, getApiBase } from './lib/api';
|
||||
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() {
|
||||
const [tab, setTab] = useState<Tab>('dashboard');
|
||||
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 (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<h1>Zacus Scratch Frontend V2</h1>
|
||||
<p>Nouvelle base from scratch, orientee Blockly + YAML + API Story V2.</p>
|
||||
<h1>Zacus Studio V2</h1>
|
||||
<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>
|
||||
|
||||
<main className="app-grid">
|
||||
<section className="panel">
|
||||
<h2>Designer blocs</h2>
|
||||
<BlocklyDesigner onYamlChange={setYaml} />
|
||||
</section>
|
||||
<nav className="tab-bar">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`tab ${tab === t.id ? 'active' : ''}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="panel">
|
||||
<h2>YAML runtime</h2>
|
||||
<Editor
|
||||
height="40vh"
|
||||
defaultLanguage="yaml"
|
||||
value={yaml}
|
||||
onChange={(value) => setYaml(value ?? '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<RuntimeControls yaml={yaml} />
|
||||
</section>
|
||||
<main className="main-content">
|
||||
{tab === 'dashboard' && (
|
||||
<ErrorBoundary fallbackLabel="Dashboard">
|
||||
<Dashboard runtime={runtime} refresh={runtime.refresh} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
{tab === 'designer' && (
|
||||
<ErrorBoundary fallbackLabel="Designer">
|
||||
<Suspense fallback={LazyFallback}>
|
||||
<div className="app-grid">
|
||||
<section className="panel">
|
||||
<h2>Designer blocs</h2>
|
||||
<LazyBlocklyDesigner
|
||||
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>
|
||||
</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,
|
||||
scenarioToYaml,
|
||||
validateScenarioDocument,
|
||||
parseYamlToSteps,
|
||||
} from '../lib/scenario';
|
||||
import {
|
||||
compileScenarioDocumentToRuntime3,
|
||||
runtime3ToJson,
|
||||
validateRuntime3Document,
|
||||
} from '../lib/runtime3';
|
||||
import type { ScenarioStep } from '../types';
|
||||
|
||||
const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
|
||||
@@ -13,14 +19,15 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
|
||||
contents: [
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Scenario',
|
||||
name: 'Steps',
|
||||
colour: '#3b82f6',
|
||||
contents: [
|
||||
{
|
||||
kind: 'block',
|
||||
type: 'zacus_step',
|
||||
},
|
||||
],
|
||||
contents: [{ 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;
|
||||
|
||||
function ensureCustomBlocks(): void {
|
||||
if (blocksRegistered) {
|
||||
return;
|
||||
}
|
||||
if (blocksRegistered) return;
|
||||
|
||||
Blockly.defineBlocksWithJsonArray([
|
||||
{
|
||||
type: 'zacus_step',
|
||||
message0: 'step %1 scene %2',
|
||||
message0: 'Step %1 Scene %2 Audio %3',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_input',
|
||||
name: 'STEP_ID',
|
||||
text: 'STEP_NEW',
|
||||
},
|
||||
{
|
||||
type: 'field_input',
|
||||
name: 'SCENE_ID',
|
||||
text: 'SCENE_NEW',
|
||||
},
|
||||
{ type: 'field_input', name: 'STEP_ID', text: 'STEP_NEW' },
|
||||
{ type: 'field_input', name: 'SCENE_ID', text: 'SCENE_NEW' },
|
||||
{ type: 'field_input', name: 'AUDIO_PACK', text: '' },
|
||||
],
|
||||
message1: '%1',
|
||||
args1: [{ type: 'input_statement', name: 'TRANSITIONS' }],
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
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 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 => {
|
||||
let cursor: Blockly.Block | null = startBlock;
|
||||
while (cursor) {
|
||||
if (seenBlocks.has(cursor.id)) {
|
||||
break;
|
||||
}
|
||||
if (seenBlocks.has(cursor.id)) break;
|
||||
seenBlocks.add(cursor.id);
|
||||
|
||||
if (cursor.type === 'zacus_step') {
|
||||
steps.push({
|
||||
stepId: cursor.getFieldValue('STEP_ID') ?? '',
|
||||
sceneId: cursor.getFieldValue('SCENE_ID') ?? '',
|
||||
audioPack: cursor.getFieldValue('AUDIO_PACK') ?? '',
|
||||
transitions: readTransitions(cursor),
|
||||
});
|
||||
}
|
||||
cursor = cursor.getNextBlock();
|
||||
@@ -80,72 +126,129 @@ function readScenarioSteps(workspace: Blockly.WorkspaceSvg): ScenarioStep[] {
|
||||
};
|
||||
|
||||
for (const block of workspace.getTopBlocks(true)) {
|
||||
if (block.type === 'zacus_step') {
|
||||
collectChain(block);
|
||||
}
|
||||
if (block.type === 'zacus_step') collectChain(block);
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
for (const block of workspace.getAllBlocks(false)) {
|
||||
if (block.type === 'zacus_step') {
|
||||
collectChain(block);
|
||||
}
|
||||
if (block.type === 'zacus_step') 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 {
|
||||
const first = workspace.newBlock('zacus_step');
|
||||
first.setFieldValue('STEP_U_SON_PROTO', 'STEP_ID');
|
||||
first.setFieldValue('SCENE_U_SON_PROTO', 'SCENE_ID');
|
||||
first.initSvg();
|
||||
first.render();
|
||||
first.moveBy(40, 40);
|
||||
function loadStepsIntoWorkspace(
|
||||
workspace: Blockly.WorkspaceSvg,
|
||||
steps: ScenarioStep[],
|
||||
): void {
|
||||
workspace.clear();
|
||||
let prevBlock: Blockly.Block | null = null;
|
||||
const Y_START = 40;
|
||||
const X_START = 40;
|
||||
|
||||
const second = workspace.newBlock('zacus_step');
|
||||
second.setFieldValue('STEP_LA_DETECTOR', 'STEP_ID');
|
||||
second.setFieldValue('SCENE_LA_DETECTOR', 'SCENE_ID');
|
||||
second.initSvg();
|
||||
second.render();
|
||||
for (const step of steps) {
|
||||
const block = workspace.newBlock('zacus_step');
|
||||
block.setFieldValue(step.stepId || 'STEP_NEW', 'STEP_ID');
|
||||
block.setFieldValue(step.sceneId || 'SCENE_NEW', 'SCENE_ID');
|
||||
block.setFieldValue(step.audioPack || '', 'AUDIO_PACK');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
|
||||
if (first.nextConnection && second.previousConnection) {
|
||||
first.nextConnection.connect(second.previousConnection);
|
||||
const transitions = step.transitions ?? [];
|
||||
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 = {
|
||||
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 workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
|
||||
const [scenarioId, setScenarioId] = useState('zacus_v2_new');
|
||||
const [steps, setSteps] = useState<ScenarioStep[]>([]);
|
||||
const [copyInfo, setCopyInfo] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!hostRef.current) return;
|
||||
|
||||
ensureCustomBlocks();
|
||||
const workspace = Blockly.inject(hostRef.current, {
|
||||
toolbox: TOOLBOX,
|
||||
trashcan: true,
|
||||
grid: {
|
||||
spacing: 20,
|
||||
length: 3,
|
||||
colour: '#d0d7de',
|
||||
snap: true,
|
||||
},
|
||||
zoom: {
|
||||
controls: true,
|
||||
wheel: true,
|
||||
startScale: 0.95,
|
||||
},
|
||||
grid: { spacing: 20, length: 3, colour: '#3a3f52', snap: true },
|
||||
zoom: { controls: true, wheel: true, startScale: 0.95 },
|
||||
});
|
||||
workspaceRef.current = workspace;
|
||||
addStarterBlocks(workspace);
|
||||
@@ -167,21 +270,25 @@ export function BlocklyDesigner({ onYamlChange }: BlocklyDesignerProps) {
|
||||
|
||||
const generated = useMemo(() => {
|
||||
const scenarioDocument = buildScenarioFromBlocks(scenarioId, steps);
|
||||
const runtime3Document = compileScenarioDocumentToRuntime3(scenarioDocument);
|
||||
return {
|
||||
scenarioDocument,
|
||||
yaml: scenarioToYaml(scenarioDocument),
|
||||
validation: validateScenarioDocument(scenarioDocument),
|
||||
runtime3Json: runtime3ToJson(runtime3Document),
|
||||
runtime3Validation: validateRuntime3Document(runtime3Document),
|
||||
};
|
||||
}, [scenarioId, steps]);
|
||||
|
||||
useEffect(() => {
|
||||
onYamlChange(generated.yaml);
|
||||
}, [generated.yaml, onYamlChange]);
|
||||
onDraftChange({
|
||||
yaml: generated.yaml,
|
||||
runtime3Json: generated.runtime3Json,
|
||||
});
|
||||
}, [generated.runtime3Json, generated.yaml, onDraftChange]);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!workspaceRef.current) {
|
||||
return;
|
||||
}
|
||||
workspaceRef.current.clear();
|
||||
if (!workspaceRef.current) return;
|
||||
addStarterBlocks(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 (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
@@ -206,22 +344,45 @@ export function BlocklyDesigner({ onYamlChange }: BlocklyDesignerProps) {
|
||||
placeholder="scenario id"
|
||||
/>
|
||||
<button type="button" onClick={handleReset}>
|
||||
Reset blocks
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" onClick={handleCopy}>
|
||||
Copy YAML
|
||||
</button>
|
||||
<button type="button" onClick={handleFileImport}>
|
||||
Import YAML
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={hostRef} className="blockly-host" />
|
||||
|
||||
<div
|
||||
className={`status-line ${generated.validation.ok ? '' : 'error'}`}
|
||||
className={`status-line ${
|
||||
generated.validation.ok && generated.runtime3Validation.ok ? '' : 'error'
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
{generated.validation.ok
|
||||
? `Ready - ${steps.length} step(s) detected. ${copyInfo}`
|
||||
: `Invalid draft: ${generated.validation.error}`}
|
||||
{generated.validation.ok && generated.runtime3Validation.ok
|
||||
? `Ready — ${steps.length} step(s), ${
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
</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
|
||||
type="button"
|
||||
disabled={busy || yaml.trim().length === 0}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #0f172a;
|
||||
background: #f5f7fa;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
|
||||
@@ -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}` }),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import type { ScenarioDocument, ScenarioStep } from '../types';
|
||||
import type { ScenarioDocument, ScenarioStep, StepTransition } from '../types';
|
||||
|
||||
const scenarioSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -19,7 +19,6 @@ const scenarioSchema = z.object({
|
||||
}),
|
||||
stations: 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(
|
||||
z.object({
|
||||
step_id: z.string().min(1),
|
||||
@@ -27,9 +26,33 @@ const scenarioSchema = z.object({
|
||||
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
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_]+/g, '_')
|
||||
@@ -38,6 +61,29 @@ function normalizeId(raw: string, fallback: string): string {
|
||||
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(
|
||||
scenarioId: string,
|
||||
steps: ScenarioStep[],
|
||||
@@ -47,37 +93,76 @@ export function buildScenarioFromBlocks(
|
||||
return {
|
||||
stepId: normalizeId(step.stepId, `STEP_${position}`),
|
||||
sceneId: normalizeId(step.sceneId, `SCENE_${position}`),
|
||||
audioPack: step.audioPack ?? '',
|
||||
actions: step.actions ?? [],
|
||||
apps: step.apps ?? [],
|
||||
transitions: (step.transitions ?? []).map(normalizeTransition),
|
||||
};
|
||||
});
|
||||
|
||||
const fallbackStep =
|
||||
normalizedSteps.length > 0
|
||||
? 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 {
|
||||
id: normalizeId(scenarioId, 'ZACUS_V2_NEW'),
|
||||
version: 1,
|
||||
title: 'Nouveau scenario Zacus',
|
||||
players: {
|
||||
min: 6,
|
||||
max: 14,
|
||||
},
|
||||
duration: {
|
||||
total_minutes: 90,
|
||||
},
|
||||
players: { min: 6, max: 14 },
|
||||
duration: { total_minutes: 105 },
|
||||
canon: {
|
||||
introduction: 'Scenario genere depuis frontend scratch-like.',
|
||||
stakes: 'Valider les transitions et deployer via API Story V2.',
|
||||
},
|
||||
stations: [],
|
||||
puzzles: [],
|
||||
steps_reference_order: fallbackStep.map((step) => step.stepId),
|
||||
steps_narrative: fallbackStep.map((step) => ({
|
||||
step_id: step.stepId,
|
||||
scene: step.sceneId,
|
||||
narrative: `Etape ${step.stepId} en scene ${step.sceneId}.`,
|
||||
steps_narrative: fallbackStep.map((s) => ({
|
||||
step_id: s.stepId,
|
||||
scene: s.sceneId,
|
||||
narrative: `Etape ${s.stepId} en scene ${s.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' };
|
||||
}
|
||||
|
||||
const uniqueStepIds = new Set(parsed.data.steps_reference_order);
|
||||
if (uniqueStepIds.size !== parsed.data.steps_reference_order.length) {
|
||||
return { ok: false, error: 'duplicate step ids in steps_reference_order' };
|
||||
const stepOrder = resolveScenarioStepOrder(parsed.data);
|
||||
const uniqueStepIds = new Set(stepOrder);
|
||||
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 };
|
||||
@@ -107,3 +221,74 @@ export function validateScenarioDocument(
|
||||
export function scenarioToYaml(document: ScenarioDocument): string {
|
||||
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')
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
export interface ScenarioStep {
|
||||
stepId: 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 {
|
||||
@@ -20,10 +39,28 @@ export interface ScenarioDocument {
|
||||
};
|
||||
stations: Array<Record<string, unknown>>;
|
||||
puzzles: Array<Record<string, unknown>>;
|
||||
steps_reference_order: string[];
|
||||
steps_narrative: Array<{
|
||||
step_id: string;
|
||||
scene: 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);
|
||||
});
|
||||
});
|
||||
@@ -47,17 +47,111 @@ firmware:
|
||||
- "SCENE_MP3_PLAYER"
|
||||
- "SCENE_READY"
|
||||
|
||||
steps_reference_order:
|
||||
- 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
|
||||
initial_step: STEP_U_SON_PROTO
|
||||
|
||||
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)
|
||||
# ============================================================
|
||||
@@ -390,4 +484,4 @@ props_and_assets:
|
||||
notes: >
|
||||
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)
|
||||
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 qu’il 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 s’organise : une personne fait le son, d’autres 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). L’ACK 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, l’installation de l’Électron Fou. Le système annonce qu’un 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 qu’elle 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 l’accè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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||