Compare commits

...

193 Commits

Author SHA1 Message Date
clement abdb6bec2c gamebook: allow GAMEBOOK_OUT_DIR override
Repo State / repo-state (pull_request) Failing after 55s
Validate Zacus refactor / validate (pull_request) Failing after 5m47s
Repo State / repo-state (push) Failing after 37s
Validate Zacus refactor / validate (push) Failing after 5m44s
2026-06-20 10:47:54 +02:00
clement 410e3a4ef7 fix(gamebook): always regenerate the PLIP menu WAV
Repo State / repo-state (pull_request) Failing after 47s
Repo State / repo-state (push) Failing after 39s
Validate Zacus refactor / validate (pull_request) Failing after 6m8s
Validate Zacus refactor / validate (push) Failing after 5m49s
The spoken story menu was cached by filename, so incremental deploys
(adding a story) kept announcing the old, shorter list. Force-rebuild
menu.wav each run since its content tracks the (cumulative) book list.
2026-06-20 09:17:56 +02:00
clement 0f11f049f9 chore: bump ESP32_ZACUS (box3 audio, plip kyutai)
Validate Zacus refactor / validate (pull_request) Failing after 6m20s
Repo State / repo-state (pull_request) Failing after 13m25s
Repo State / repo-state (push) Failing after 13m58s
Validate Zacus refactor / validate (push) Failing after 7m32s
2026-06-20 01:13:31 +02:00
clement af5d99e901 feat(gamebook): Kyutai backend for master gen
Validate Zacus refactor / validate (pull_request) Failing after 7m8s
Repo State / repo-state (pull_request) Failing after 10m4s
Validate Zacus refactor / validate (push) Failing after 14m8s
Repo State / repo-state (push) Failing after 14m24s
Add --tts say|kyutai to build_gamebook.py (the screen/BOX-3 generator),
mirroring the phone one: POST to the local Kyutai :8302 server, resample
24->16k via afconvert. Lets the BOX-3 use the nicer neural FR voice.
2026-06-20 01:11:39 +02:00
clement 0c1802285a feat(gamebook): text narration + --only + bump
Validate Zacus refactor / validate (pull_request) Failing after 8m11s
Repo State / repo-state (pull_request) Failing after 13m35s
Repo State / repo-state (push) Failing after 14m50s
Validate Zacus refactor / validate (push) Failing after 20m57s
--narrate-text makes WAV narration the passage text alone (the BOX-3
shows choices as buttons, no need to speak them). --only builds a
subset incrementally; library.json always lists ALL books so it never
drops tiles. Bumps ESP32_ZACUS for BOX-3 audio.
2026-06-20 01:04:41 +02:00
clement e478454070 feat(gamebook): --json-only for BOX-3 + bump fw
Repo State / repo-state (pull_request) Failing after 32s
Repo State / repo-state (push) Failing after 39s
Validate Zacus refactor / validate (pull_request) Failing after 6m15s
Validate Zacus refactor / validate (push) Failing after 8m9s
Add --json-only to the gamebook generator: build/push only the per-book
JSON + library.json (no WAVs) — the BOX-3 touch UI reads text on screen,
no audio needed. Bumps ESP32_ZACUS for the BOX-3 touch gamebook.
2026-06-20 00:37:58 +02:00
clement 0ac86b3c3d feat(gamebook): Kyutai neural TTS for phone pack
Repo State / repo-state (pull_request) Failing after 48s
Repo State / repo-state (push) Failing after 43s
Validate Zacus refactor / validate (pull_request) Failing after 6m1s
Validate Zacus refactor / validate (push) Failing after 5m49s
Add a local Kyutai TTS 1.6B (en_fr, MLX) backend to the PLIP phone pack
generator (--tts kyutai): POST to the local :8302 server, resample 24
-> 16 kHz mono via afconvert, canonicalise. Much nicer French voice
than say. Add --only to build/deploy stories incrementally (the 960 MB
PLIP SD fits ~3-4); menu + library cover exactly the built books;
filename cache makes long runs resumable. Bumps fw (MAX_BODY 3 MB).
2026-06-20 00:20:39 +02:00
clement 41c574b1c5 feat(gamebook): PLIP phone audio pack + bump fw
Repo State / repo-state (pull_request) Failing after 44s
Validate Zacus refactor / validate (pull_request) Failing after 6m28s
Validate Zacus refactor / validate (push) Failing after 5m57s
Repo State / repo-state (push) Failing after 13m53s
Build the phone audio gamebook pack (narration WAVs with spoken numbered
choices + a story menu WAV + per-book JSON + library.json) and stage it
on the PLIP SD. Bumps ESP32_ZACUS for the PLIP audio gamebook and the
master full-height scrolling nav.
2026-06-19 23:58:09 +02:00
clement 72412ca1fe content: expand kid gamebooks ~10x (815 passages)
Repo State / repo-state (pull_request) Failing after 50s
Repo State / repo-state (push) Failing after 43s
Validate Zacus refactor / validate (pull_request) Failing after 5m46s
Validate Zacus refactor / validate (push) Failing after 5m48s
Context:
The six age-11 stories were short (12-24 passages each, 87 total). We
want much deeper, more replayable branching adventures — roughly ten
times the passages.

Approach:
Rewrite each story in place (same titles/heroes/universe, same library
of six tiles), greatly expanding the world with new locations,
characters and branches. One book per file, authored in parallel, then
every graph validated independently.

Changes (passages before -> after):
- dragon_flamme   24 -> 131  (14 endings)
- ecole_sorciers  13 -> 137  (10)
- hamster_disparu 12 -> 132  (13)
- planete_zigzag  12 -> 131  (11)
- tresor_caramel  13 -> 151  (10)
- voyage_dinos    13 -> 133  (10)
- Total 87 -> 815 passages, 68 endings, ~1928 choices.

Every graph validated: YAML loads, single start, no broken gotos, every
passage reachable, <=4 choices/passage, screen<=40 & label<=38 chars,
gentle kid-safe endings (failures loop back or end softly).

Impact:
Far richer, replayable stories for the boot library. Text only here —
regenerate/stage WAVs with tools/gamebook/build_gamebook.py when the
Freenove is back online (currently unplugged).
2026-06-19 21:14:27 +02:00
clement 616aeca14e feat(gamebook): screen text cap 1000 + bump fw
Repo State / repo-state (push) Failing after 1m0s
Validate Zacus refactor / validate (push) Failing after 6m3s
Match the firmware's scrolling body (no more clipped passages) and bump
ESP32_ZACUS for the scroll fix + DELETE /game/file.
2026-06-19 16:31:03 +02:00
clement 2b6a952e10 content: longer, more narrative kid gamebooks
Repo State / repo-state (pull_request) Failing after 40s
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 5m48s
Validate Zacus refactor / validate (push) Failing after 5m38s
Context:
The six age-11 stories were short and a bit dry (one or two sentences
per passage). We want richer, more immersive storytelling for a young
reader/listener.

Approach:
Rewrite all six in place (same filenames, library stays six tiles),
keeping the age-11 tone and gentle "oops, try again" endings, but with
much fuller prose: scene-setting, dialogue, a little character and
emotion, and deeper choice trees with more real branches.

Changes:
- dragon_flamme: ~600 -> ~2050 words, 24 passages, 4 endings.
- ecole_sorciers, hamster_disparu, planete_zigzag, tresor_caramel,
  voyage_dinos: each roughly doubled (~1050-1260 words), 12-13
  passages, 2-3 endings.
- All graphs validated: no broken gotos, every passage reachable.

Impact:
The boot library now tells proper little stories (the WAV narration
reads the full text; the screen shows the opening of each passage).
Text only here — rebuild/stage WAVs with the generator to deploy.
2026-06-19 16:04:56 +02:00
clement 24de4de4e2 feat(gamebook): library generator + bump fw
Repo State / repo-state (pull_request) Failing after 38s
Repo State / repo-state (push) Failing after 29s
Validate Zacus refactor / validate (pull_request) Failing after 5m44s
Validate Zacus refactor / validate (push) Failing after 5m58s
Build all game/gamebooks/*.yaml into per-book <id>.json + a library.json
index (flat <id>_<passage>.wav naming), staged to /sdcard/gamebook/.
Bumps ESP32_ZACUS for the boot tile picker.
2026-06-19 15:25:04 +02:00
clement a9953172e4 content: kid-friendly gamebook library (age 11)
Context:
The gamebook tiles were filled with fairly dark adventures (haunted
manor, deaths, being eaten/frozen) — not suitable for an 11-year-old.
We want the boot library to be age-appropriate.

Approach:
Write six light, branching stories pitched at ~11 years (adventure +
humour, gentle "oops, try again" failures, never grim), and move the
six mature adventures into a non-library subfolder so they're kept but
out of the kids' picker (the generator globs game/gamebooks/*.yaml,
non-recursive). Text only — no WAV synthesis here.

Changes:
- New kid stories in game/gamebooks/: dragon_flamme, tresor_caramel,
  planete_zigzag, hamster_disparu, ecole_sorciers, voyage_dinos
  (11-17 passages each, 2-5 endings, all reachable, no broken gotos).
- Move the 6 mature adventures to game/gamebooks/aventures/ (archived,
  excluded from the library glob).

Impact:
The 6-tile boot library now offers age-appropriate stories. Build/stage
them with tools/gamebook/build_gamebook.py when ready (not done here).
2026-06-19 15:23:08 +02:00
clement bfa09b68be content: three long text gamebooks
Repo State / repo-state (push) Failing after 49s
Repo State / repo-state (pull_request) Failing after 46s
Validate Zacus refactor / validate (push) Failing after 5m38s
Context:
The gamebook engine had a single short demo. We want several long,
text-only "livre dont vous etes le heros" adventures to play on the
Freenove (and to exercise deeper branching).

Approach:
Author each as a standalone gamebook YAML in game/gamebooks/, using the
navigable format (passages with screen/text, choices as label+goto;
a choiceless passage is an ending that restarts on click).

Changes:
- game/gamebooks/tour_horloger.yaml: "La Tour de l'Horloger"
  (fantastique/temps) — 23 passages, 8 fins.
- game/gamebooks/train_de_nuit.yaml: "Le Dernier Train de Nuit"
  (mystere/huis clos) — 19 passages, 7 fins.
- game/gamebooks/ile_echos.yaml: "L'Ile aux Mille Echos"
  (aventure/survie) — 16 passages, 7 fins.

Impact:
Three richly branched stories ready to stage on the master SD with
tools/gamebook/build_gamebook.py. Graphs validated: no broken gotos,
every passage reachable, multiple endings each.
2026-06-19 14:41:54 +02:00
clement 05c4c11c0b feat: gamebook authoring + demo + bump fw
Repo State / repo-state (pull_request) Failing after 35s
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 5m58s
Validate Zacus refactor / validate (push) Failing after 7m16s
Context:
The new firmware gamebook mode needs a book on the Freenove SD, plus a
reusable way to author one. We also want a playable demo to prove the
whole chain (button -> navigation -> WAV narration from SD -> screen).

Approach:
Author the book in a human-friendly YAML; a generator renders each
passage's narration with macOS say (16 kHz mono, canonical WAV — the
Apple FLLR chunk is stripped so the firmware parser decodes it),
emits a compact gamebook.json, and stages WAVs + JSON onto the master
SD via POST /game/file?path=sd/gamebook/.

Changes:
- game/gamebooks/zacus_demo.yaml: a branching mini-adventure (the
  Professeur Zacus lab) — 8 passages, 3 endings.
- tools/gamebook/build_gamebook.py: reusable/idempotent generator
  (--book/--ip/--sd-dir/--dry-run); ASCII-folds on-screen text (fonts
  have no accents) while the WAV keeps the accented narration; choices
  carry label + goto (navigation is generic, D-pad + click).
- .gitignore: ignore the generated WAV pack (tools/gamebook/build/).
- Bump ESP32_ZACUS to the gamebook-mode firmware.

Impact:
Authors can write a gamebook in YAML and stage it to the Freenove in
one command; the bundled demo plays end to end, fully offline.
2026-06-19 14:31:50 +02:00
clement 036b3e347d chore: bump ESP32_ZACUS (DELETE /game/file)
Repo State / repo-state (push) Failing after 57s
Validate Zacus refactor / validate (pull_request) Failing after 6m19s
Validate Zacus refactor / validate (push) Failing after 5m50s
Repo State / repo-state (pull_request) Failing after 10m12s
2026-06-18 21:58:20 +02:00
clement 8a9a5a2063 feat(gateway): pass scene to PLIP ring + bump fw
Validate Zacus refactor / validate (push) Failing after 5m45s
Validate Zacus refactor / validate (pull_request) Failing after 10m58s
Repo State / repo-state (pull_request) Failing after 11m22s
Repo State / repo-state (push) Failing after 11m29s
Auto-call now forwards the scene to /debug/ring so the phone plays
that scene's hint from its local SD pack after the greeting. Bumps
ESP32_ZACUS for the chained-hint firmware.
2026-06-18 21:32:39 +02:00
clement f55da57b2c fix(tts): strip Apple FLLR chunk -> canonical WAV
Repo State / repo-state (pull_request) Failing after 41s
Repo State / repo-state (push) Failing after 41s
Validate Zacus refactor / validate (pull_request) Failing after 5m52s
Validate Zacus refactor / validate (push) Failing after 6m6s
macOS say inserts a ~4 KB FLLR filler chunk between fmt and data. The
PLIP firmware parses only a small header buffer and never reaches a
data chunk pushed past it (WAV parse failed -> silent). Rebuild a
canonical 44-byte-header RIFF/fmt/data WAV before staging. Verified:
Zacus greeting plays 5.05s from /sdcard on pickup, no gateway/model.
2026-06-18 18:16:27 +02:00
clement fbe9c7dfb7 chore: bump ESP32_ZACUS (PLIP greeting from SD)
Repo State / repo-state (pull_request) Failing after 41s
Repo State / repo-state (push) Failing after 42s
Validate Zacus refactor / validate (pull_request) Failing after 5m52s
Validate Zacus refactor / validate (push) Failing after 6m7s
2026-06-18 18:09:27 +02:00
clement e73db8800c feat(tts): PLIP SD voice-pack generator + bump fw
Repo State / repo-state (pull_request) Failing after 55s
Validate Zacus refactor / validate (pull_request) Failing after 6m2s
Validate Zacus refactor / validate (push) Failing after 6m7s
Repo State / repo-state (push) Failing after 13m24s
Pre-render directory greetings + scene hints with macOS say (16 kHz
mono FR) and stage them on the PLIP microSD via POST /game/file — gives
the phone local voice content with no model in RAM. Reusable/idempotent
(tools/tts/generate_plip_sd_pack.py). Bumps ESP32_ZACUS for the SD write
path. Generated WAVs gitignored (reproducible).
2026-06-18 17:43:08 +02:00
clement 92cbff1858 chore: bump ESP32_ZACUS (wire scene notify)
Repo State / repo-state (pull_request) Failing after 53s
Validate Zacus refactor / validate (pull_request) Failing after 5m37s
Repo State / repo-state (push) Failing after 44s
Validate Zacus refactor / validate (push) Failing after 5m45s
2026-06-18 15:53:23 +02:00
clement 26046a859b chore: bump ESP32_ZACUS (async gateway notify)
Repo State / repo-state (pull_request) Failing after 1m28s
Validate Zacus refactor / validate (push) Failing after 11m0s
Repo State / repo-state (push) Failing after 11m27s
Validate Zacus refactor / validate (pull_request) Failing after 11m35s
2026-06-18 15:18:40 +02:00
clement b80744d231 feat(game): warning scene auto-calls Zacus
Repo State / repo-state (pull_request) Failing after 46s
Validate Zacus refactor / validate (pull_request) Failing after 6m6s
Validate Zacus refactor / validate (push) Failing after 6m4s
Repo State / repo-state (push) Failing after 12m16s
Enable call_on_scene SCENE_WARNING -> 0142738200: Professeur Zacus
rings the PLIP when the scenario reaches the warning scene.
2026-06-18 15:02:51 +02:00
clement 37eab47f63 feat(voice): scenario-driven incoming calls
Repo State / repo-state (pull_request) Failing after 1m5s
Repo State / repo-state (push) Failing after 55s
Validate Zacus refactor / validate (pull_request) Failing after 6m20s
Validate Zacus refactor / validate (push) Failing after 13m3s
The phone now rings on cue from the game with NO firmware change. A
call_on_scene map (phone_directory.yaml: {SCENE_X: number}) tells the
gateway which scenes are scripted incoming calls; since the runtime
already pushes the scene to /game/step, the gateway auto-rings the PLIP
with that NPC on scene entry (deduped, once per entry). Factored a
_ring_board helper (shared by /game/call + auto-ring). Map ships empty
(no surprise rings) with commented examples for the designer.
2026-06-18 14:57:08 +02:00
clement 42a3b22823 feat(voice): NPC hints follow runtime scene
Repo State / repo-state (pull_request) Failing after 44s
Repo State / repo-state (push) Failing after 57s
Validate Zacus refactor / validate (pull_request) Failing after 6m47s
Validate Zacus refactor / validate (push) Failing after 5m45s
The gateway now hints on the SCENE the runtime pushes (idf_zacus POSTs
its native SCENE_* to /game/step?scene=…) — no step<->scene mapping
needed since both sides use the same SCENE_* ids. _active_scene() uses
the pushed scene (falling back to the current step's scene). Bump
ESP32_ZACUS to 653a299 (npc_engine pushes scene changes).
2026-06-18 14:05:35 +02:00
clement dffcca961b feat(voice): rotate NPC hint variants per turn
Repo State / repo-state (pull_request) Failing after 55s
Validate Zacus refactor / validate (push) Failing after 6m40s
Validate Zacus refactor / validate (pull_request) Failing after 13m40s
Repo State / repo-state (push) Failing after 14m55s
2026-06-18 12:58:06 +02:00
clement 892fb13bb3 feat(voice): /game/call — runtime rings NPC
The runtime triggers a scenario phone call: POST /game/call?number=18
[&step=STEP_X] rings the PLIP handset with the chosen NPC (resolved via
the boards registry) and sets the current step so the NPC disguises that
step's hint. Completes the game->phone trigger path (chantier #2).
2026-06-18 12:40:04 +02:00
clement 5d7550b993 feat(voice): scenario-driven NPC hints
Wire the phone NPCs to the scenario state machine + hint bank so a call
ADVANCES the puzzle instead of being generic chat. load_scenario_hints
maps each step (zacus_v3.yaml screen_scene_id) to npc_phrases.yaml
hints[scene][level]; voice/reply weaves the CURRENT step's hint into the
NPC persona, escalating the level (subtle->near-explicit) with the
conversation. New GET/POST /game/step lets the runtime set the current
step as the player progresses.
2026-06-18 10:47:55 +02:00
clement 44cd100a80 fix(voice): RMS-amplify STT input for weak voice
Repo State / repo-state (push) Failing after 51s
Validate Zacus refactor / validate (pull_request) Failing after 6m24s
Validate Zacus refactor / validate (push) Failing after 5m57s
Repo State / repo-state (pull_request) Failing after 10m40s
Condition the handset capture by RMS (not peak) before whisper: the
voice body is ~0.5-1.5% FS and a transient peak defeated peak-normalise,
leaving it inaudible. RMS-normalise to ~20% FS + clip. Recovers weak
captures whisper returned '' for (verified: -> 'C'est parti !').
2026-06-18 00:31:37 +02:00
clement 73fa3650a1 feat(voice): Whisper STT (robust to handset voice)
Validate Zacus refactor / validate (push) Failing after 12m37s
Repo State / repo-state (push) Failing after 13m3s
Validate Zacus refactor / validate (pull_request) Failing after 13m17s
Repo State / repo-state (pull_request) Failing after 13m35s
Switch the voice-loop STT from Kyutai to whisper.cpp large-v3-turbo
(subprocess, high-pass conditioned): it decodes the weak/noisy SLIC
handset captures Kyutai returned '' for. Filter whisper's silence/noise
hallucinations (sous-titrage, radio-canada, tipeurs, '...') so the NPC
never replies to a phantom line. Bump ESP32_ZACUS to 54022ed (VAD
recalibration for the quiet voice + bell stop on pickup).
2026-06-18 00:08:59 +02:00
clement 643c3852de feat(voice): local-only LLM + faster turns
Repo State / repo-state (pull_request) Failing after 43s
Validate Zacus refactor / validate (pull_request) Failing after 6m35s
Repo State / repo-state (push) Failing after 45s
Validate Zacus refactor / validate (push) Failing after 13m52s
Gateway: drop the remote ailiance fallback — the NPC LLM is now the
on-box granite ONLY (local spoken line on failure, never remote).
Prewarm the persona KV-cache in the background at greeting time so the
first reply is fast instead of paying ~17 s of cold prompt processing.
Add temperature + repetition_penalty so granite4-tiny stops looping.
STT: cut the trailing-silence pad 2 s -> 0.5 s (callers already pad),
shaving STT latency. Bump ESP32_ZACUS to cfe429d (hook debounce).
2026-06-17 19:49:10 +02:00
clement 0764c873a7 chore: bump firmware + faster LLM failover
Repo State / repo-state (pull_request) Failing after 1m14s
Repo State / repo-state (push) Failing after 1m10s
Validate Zacus refactor / validate (push) Failing after 13m51s
Bump ESP32_ZACUS to 82759ee (voice-activated two-phase capture, mic
diag). Gateway: drop the local-LLM (granite) timeout 70s -> 8s so a
cold/hung local model fails over to the remote backend fast instead of
stalling the whole turn past the firmware's reply timeout.
2026-06-17 14:41:02 +02:00
electron 27ab75fded Merge pull request 'fix(plip): handset mic capture — ES8388 DLL + STT high-pass' (#164) from fix/plip-mic-stt-highpass into main
Repo State / repo-state (push) Failing after 1m2s
Validate Zacus refactor / validate (push) Failing after 11m22s
2026-06-17 07:24:28 +00:00
clement af7c02d1a7 chore: bump ESP32_ZACUS (no hook forcing)
Repo State / repo-state (pull_request) Failing after 1m6s
Repo State / repo-state (push) Failing after 11m16s
Validate Zacus refactor / validate (pull_request) Failing after 19m7s
Pull in ESP32_ZACUS fix(plip) 8c076d8: gate audio playback on the real
SHK line and remove all hook forcing (s_hook_override,
phone_force_offhook, /debug/offhook). Fixes the silent incoming-call
greeting caused by the two off-hook flags desyncing during ring.
2026-06-17 09:06:19 +02:00
clement fb71e9ceeb fix(stt): reset LM KV-cache per request
Repo State / repo-state (push) Failing after 10m58s
Repo State / repo-state (pull_request) Failing after 11m34s
Validate Zacus refactor / validate (pull_request) Failing after 11m39s
The Lm holds a persistent rotating transformer_cache shared by every
LmGen. LmGen only resets its own gen_sequence/step_idx, so across
requests the cache kept accumulating stream positions until it
saturated and the model emitted only padding tokens, returning empty
transcriptions ('') for every request. Reset transformer_cache (and
depformer_cache) per request, mirroring Lm.warmup(). Validated: 40
consecutive requests of a known-good sample, 0 empty.
2026-06-17 08:26:05 +02:00
clement f703611388 chore: bump ESP32_ZACUS — status + capture fix
Repo State / repo-state (push) Failing after 2m3s
Repo State / repo-state (pull_request) Failing after 10m11s
Validate Zacus refactor / validate (pull_request) Failing after 15m3s
2026-06-16 19:19:35 +02:00
clement f8e576ba5a chore: bump ESP32_ZACUS — incoming mode + volume
Repo State / repo-state (pull_request) Failing after 10m51s
Repo State / repo-state (push) Failing after 12m5s
Validate Zacus refactor / validate (pull_request) Failing after 18m38s
2026-06-16 10:32:43 +02:00
clement 8d90a25756 fix(gateway): snappier LLM keep-warm + higher timeout
- keep-warm interval 600s -> 240s (avoids ~47s cold-start fallback)
- local backend timeout 60s -> 70s (tolerates cold reload under 90s)
2026-06-16 10:32:37 +02:00
clement 1e15bba258 chore: bump ESP32_ZACUS — ES8388 DLL ADC fix
Repo State / repo-state (pull_request) Failing after 10m56s
Repo State / repo-state (push) Failing after 12m4s
2026-06-16 09:35:20 +02:00
clement bc5363cf2c fix(gateway): STT high-pass + gain for SLIC mic
SLIC handset injects low-frequency rumble/DC drift that swamps speech
and causes Kyutai STT to output nothing. Apply a box moving-average
high-pass (~110 Hz) before peak-normalisation. Also raise gain cap to
x40 and target_peak to 0.7 (mic is still very quiet post-ES8388 fix).
Verified: same capture transcribes empty without HP, clean French with.
2026-06-16 09:35:00 +02:00
clement 49ec7d9dc7 chore: bump ESP32_ZACUS (es8388 debug endpoint)
Validate Zacus refactor / validate (push) Failing after 10m8s
Repo State / repo-state (pull_request) Failing after 13m58s
Repo State / repo-state (push) Failing after 14m57s
Validate Zacus refactor / validate (pull_request) Failing after 22m47s
2026-06-16 08:42:40 +02:00
clement 20c0431f51 feat(gateway): normalize low-level STT capture
Add _wav_pcm(), _build_wav(), _normalize_wav_for_stt() (peak-normalize
with capped gain) to handle the PLIP handset mic low signal level and
placeholder 0xFFFFFFFF WAV headers from /voice/capture stream.
Rewire _pad_wav_silence() and _transcribe_kyutai via these helpers.
2026-06-16 08:41:43 +02:00
clement 68c81fa3c9 feat(gateway): say TTS, local LLM, STT padding
- Replace Kyutai TTS with macOS say (16 kHz mono FR);
  NPC voice map _SAY_VOICE_MAP; helper _tts_say_sync
- Local vllm-mlx :8520 as primary LLM (_chat_one),
  ailiance as fallback, spoken phrase as last resort;
  _chat_reply never raises (loop stays alive on 502)
- Pad 800 ms silence before /transcribe (_pad_wav_silence)
  to prevent last-word truncation
- Background _keep_llm_warm ping every 600 s (cold start)
- Root log level INFO; X-Zacus-Heard/Said headers
- test_chat_reply: empty_choices expects spoken fallback
2026-06-15 22:18:55 +02:00
clement da205ffc99 feat(gateway): Kyutai STT/TTS + voice reply loop
Repo State / repo-state (pull_request) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 6m12s
Validate Zacus refactor / validate (push) Failing after 5m38s
Repo State / repo-state (push) Failing after 11m29s
- tools/kyutai-stt: local FastAPI STT server
  (kyutai_stt_server.py, stt_from_file_mlx.py)
  + TTS server (kyutai_tts_server.py, tts_mlx.py)
  using MLX Kyutai models
- gateway /v1/voice/reply: STT → LLM → TTS pipeline
  + dynamic soxr resample + compressor/limiter chain
  + greeting cache + warm-up at startup
- tests/gateway/test_voice_reply.py: pytest coverage
- bump ESP32_ZACUS submodule to plip voice loop (aa7ae27)
2026-06-15 21:14:25 +02:00
electron 97303f0c58 Merge pull request 'chore: bump ESP32_ZACUS — PLIP mic LIN2' (#161) from chore/bump-plip-lin2 into main
Repo State / repo-state (push) Failing after 45s
Validate Zacus refactor / validate (push) Failing after 5m55s
2026-06-15 07:52:07 +00:00
clemsail 81e02a897a chore: bump ESP32_ZACUS — PLIP mic on ADC LIN2 (voice captured)
Validate Zacus refactor / validate (pull_request) Failing after 11m45s
Repo State / repo-state (pull_request) Failing after 12m23s
Repo State / repo-state (push) Failing after 12m55s
2026-06-15 09:51:50 +02:00
electron 87aeb58171 Merge pull request 'chore: bump ESP32_ZACUS — PLIP hook polarity' (#160) from chore/bump-plip-hook into main
Repo State / repo-state (push) Failing after 41s
Validate Zacus refactor / validate (push) Failing after 6m8s
2026-06-15 07:44:46 +00:00
clemsail 0b75fc6ddb chore: bump ESP32_ZACUS — PLIP hook polarity active-LOW
Repo State / repo-state (pull_request) Failing after 41s
Repo State / repo-state (push) Failing after 1m0s
Validate Zacus refactor / validate (pull_request) Failing after 6m1s
2026-06-15 09:44:25 +02:00
electron 81e6a786c5 Merge pull request 'chore: bump ESP32_ZACUS — PLIP SLIC + I2S mic fix' (#159) from chore/bump-plip-slic into main
Repo State / repo-state (push) Failing after 38s
Validate Zacus refactor / validate (push) Failing after 5m32s
2026-06-14 23:02:14 +00:00
clemsail 74fe9639d9 chore: bump ESP32_ZACUS — PLIP SLIC integration + I2S RX mic fix
Repo State / repo-state (pull_request) Failing after 1m8s
Repo State / repo-state (push) Failing after 28s
Validate Zacus refactor / validate (pull_request) Failing after 5m46s
2026-06-15 01:02:00 +02:00
electron 5d9b735a2d Merge pull request 'chore: bump ESP32_ZACUS — PLIP phone stage 2' (#158) from chore/bump-plip-stage2 into main
Repo State / repo-state (push) Failing after 30s
Validate Zacus refactor / validate (push) Failing after 11m32s
2026-06-14 20:45:38 +00:00
clemsail 9c84ef9d3d chore: bump ESP32_ZACUS — PLIP phone stage 2 (turn_client + GREET)
Repo State / repo-state (push) Failing after 26s
Repo State / repo-state (pull_request) Failing after 26s
Validate Zacus refactor / validate (pull_request) Failing after 5m39s
2026-06-14 22:45:24 +02:00
electron 5e88ddef65 Merge pull request 'feat(gateway): téléphone PLIP stage 2 — annuaire + /v1/voice/turn (greeting)' (#157) from feat/gateway-voice-turn into main
Repo State / repo-state (push) Failing after 26s
Validate Zacus refactor / validate (push) Failing after 5m28s
2026-06-14 20:34:32 +00:00
clemsail 502aa8dc5b fix(gateway): voice session TTL sweep + 502 on backend down + chat parse guard + reply user msg
Repo State / repo-state (pull_request) Failing after 33s
Repo State / repo-state (push) Failing after 29s
Validate Zacus refactor / validate (pull_request) Failing after 5m35s
2026-06-14 22:33:09 +02:00
clemsail 0e517829f1 feat(gateway): POST /v1/voice/turn (greeting path) + /v1/voice/end 2026-06-14 22:24:50 +02:00
clemsail f5de35342c feat(gateway): ailiance chat helper for NPC replies 2026-06-14 22:22:52 +02:00
clemsail 40373a5693 feat(gateway): in-memory voice sessions (history + TTL) 2026-06-14 22:22:04 +02:00
clemsail 3066e63b24 feat(gateway): phone directory loader (numéro → persona/voix) 2026-06-14 22:21:27 +02:00
electron b66724889b Merge pull request 'chore: bump ESP32_ZACUS — PLIP phone stage 1' (#156) from chore/bump-plip-stage1 into main
Repo State / repo-state (push) Failing after 34s
Validate Zacus refactor / validate (push) Failing after 5m32s
2026-06-14 20:18:04 +00:00
clemsail 8f3991eca9 chore: bump ESP32_ZACUS — PLIP phone stage 1 (tones+dialer+state machine)
Repo State / repo-state (pull_request) Failing after 41s
Repo State / repo-state (push) Failing after 42s
Validate Zacus refactor / validate (pull_request) Failing after 5m44s
2026-06-14 22:17:44 +02:00
electron 2b53c5be8e Merge pull request 'docs(plip): plan implémentation téléphone conversationnel' (#155) from docs/plip-telephone-plan into main
Repo State / repo-state (push) Failing after 35s
Validate Zacus refactor / validate (push) Failing after 5m40s
2026-06-14 19:23:07 +00:00
clemsail 5da6ec4c11 docs(plip): implementation plan — téléphone conversationnel (4 stages, TDD)
Repo State / repo-state (push) Failing after 32s
Repo State / repo-state (pull_request) Failing after 36s
Validate Zacus refactor / validate (pull_request) Failing after 5m47s
2026-06-14 21:23:00 +02:00
electron 6ccbf2c5c7 Merge pull request 'docs(plip): design téléphone conversationnel France Télécom' (#154) from docs/plip-telephone-design into main
Repo State / repo-state (push) Failing after 28s
Validate Zacus refactor / validate (push) Failing after 5m27s
2026-06-14 19:13:48 +00:00
clemsail 904a77a08b docs(plip): design — téléphone conversationnel France Télécom (annuaire 12/3615/15/17/18, /v1/voice/turn)
Repo State / repo-state (pull_request) Failing after 39s
Repo State / repo-state (push) Failing after 30s
Validate Zacus refactor / validate (pull_request) Failing after 5m43s
2026-06-14 21:13:40 +02:00
electron ddb194f32b Merge pull request 'chore: bump ESP32_ZACUS — PLIP mute on-hook' (#153) from chore/bump-plip-mute into main
Repo State / repo-state (push) Failing after 25s
Validate Zacus refactor / validate (push) Failing after 5m34s
2026-06-14 18:45:47 +00:00
clemsail 8232adb446 chore: bump ESP32_ZACUS — PLIP mute audio on-hook
Repo State / repo-state (pull_request) Failing after 1m14s
Repo State / repo-state (push) Failing after 33s
Validate Zacus refactor / validate (pull_request) Failing after 6m2s
2026-06-14 20:45:38 +02:00
electron e9563f693f Merge pull request 'fix(gateway): raise /v1/voice/say cap 64K->240K (full TTS phrases)' (#152) from fix/voice-say-240k into main
Repo State / repo-state (push) Failing after 27s
Validate Zacus refactor / validate (push) Failing after 5m37s
2026-06-14 18:33:21 +00:00
clemsail c8e03bc199 fix(gateway): raise /v1/voice/say cap 64K->240K to match PLIP streaming MAX_BODY (full phrases)
Repo State / repo-state (pull_request) Failing after 37s
Repo State / repo-state (push) Failing after 24s
Validate Zacus refactor / validate (pull_request) Failing after 5m34s
2026-06-14 20:32:53 +02:00
electron e2da1b5ccd Merge pull request 'chore: bump ESP32_ZACUS — PLIP streaming file IO (full TTS phrases)' (#151) from chore/bump-plip-maxbody into main
Repo State / repo-state (push) Failing after 31s
Validate Zacus refactor / validate (push) Failing after 5m45s
2026-06-14 18:31:18 +00:00
clemsail 77dbfd4a45 chore: bump ESP32_ZACUS — PLIP streaming upload/playback (256K, full TTS)
Repo State / repo-state (pull_request) Failing after 29s
Validate Zacus refactor / validate (pull_request) Failing after 5m53s
Repo State / repo-state (push) Failing after 46s
2026-06-14 20:31:06 +02:00
electron ad3347a8bf Merge pull request 'feat(gateway): POST /v1/voice/say — TTS via gateway.ailiance.fr → resample 16k → push+play' (#150) from feat/gateway-voice-say into main
Repo State / repo-state (push) Failing after 37s
Validate Zacus refactor / validate (push) Failing after 5m49s
2026-06-14 18:18:59 +00:00
clemsail d834d0acd2 feat(gateway): POST /v1/voice/say — TTS via gateway.ailiance.fr, resample 16k, push+play on board
Repo State / repo-state (pull_request) Failing after 39s
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 5m44s
2026-06-14 20:17:23 +02:00
electron e409d1f48c Merge pull request 'chore: bump ESP32_ZACUS — PLIP mic capture + ES8388 ADC fix' (#149) from chore/bump-plip-mic into main
Repo State / repo-state (push) Failing after 36s
Validate Zacus refactor / validate (push) Failing after 5m35s
2026-06-14 16:53:33 +00:00
clemsail 8537b90371 chore: bump ESP32_ZACUS — PLIP mic capture + ES8388 ADC fix
Repo State / repo-state (pull_request) Failing after 41s
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 5m45s
2026-06-14 18:53:21 +02:00
electron df24038638 Merge pull request 'feat(atelier): board field on physical puzzles + tests + complete blocks scenario' (#148) from feat/atelier-physical-puzzles into main
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (push) Failing after 5m48s
2026-06-14 15:09:36 +00:00
clemsail 56c44eaf4f feat(atelier): board field on morse/nfc/coffre/led/radio puzzles + tests + complete blocks scenario
Repo State / repo-state (pull_request) Failing after 37s
Repo State / repo-state (push) Failing after 27s
Validate Zacus refactor / validate (pull_request) Failing after 5m49s
2026-06-14 17:08:22 +02:00
electron 1b67a4820c Merge pull request 'chore: bump ESP32_ZACUS — P5 morse + P6 nfc scaffold' (#147) from chore/bump-p5-p6 into main
Repo State / repo-state (push) Failing after 39s
Validate Zacus refactor / validate (push) Failing after 5m37s
2026-06-14 15:00:58 +00:00
clemsail 4bee6ccf82 chore: bump ESP32_ZACUS — P5 morse + P6 nfc scaffold (Kconfig, default off)
Repo State / repo-state (pull_request) Failing after 34s
Repo State / repo-state (push) Failing after 27s
Validate Zacus refactor / validate (pull_request) Failing after 5m44s
2026-06-14 17:00:51 +02:00
electron e075dee4f2 Merge pull request 'chore: bump ESP32_ZACUS — P7 coffre scaffold (default off)' (#146) from chore/bump-p7-scaffold into main
Repo State / repo-state (push) Failing after 33s
Validate Zacus refactor / validate (push) Failing after 5m35s
2026-06-14 14:16:02 +00:00
clemsail b214cc3850 chore: bump ESP32_ZACUS — P7 coffre scaffold (Kconfig, default off)
Repo State / repo-state (pull_request) Failing after 31s
Repo State / repo-state (push) Failing after 29s
Validate Zacus refactor / validate (pull_request) Failing after 5m53s
2026-06-14 16:15:39 +02:00
electron 15936b4b40 Merge pull request 'feat(gateway): annex CMDs over HTTP /game/cmd + submodule bump' (#145) from feat/gateway-direct-cmd into main
Repo State / repo-state (push) Failing after 29s
Validate Zacus refactor / validate (push) Failing after 5m40s
2026-06-14 12:14:28 +00:00
clemsail 5892c17d63 chore: bump ESP32_ZACUS — annex /game/cmd (HTTP-direct CMD)
Repo State / repo-state (pull_request) Failing after 29s
Repo State / repo-state (push) Failing after 30s
Validate Zacus refactor / validate (pull_request) Failing after 5m55s
2026-06-14 14:13:59 +02:00
clemsail 780a936391 feat(gateway): route annex CMDs over HTTP /game/cmd when the board has an IP 2026-06-14 13:54:55 +02:00
electron a88676b4cb Merge pull request 'feat(plip): land ESP-IDF migration' (#144) from chore/plip-idf-land into main
Repo State / repo-state (push) Failing after 44s
Validate Zacus refactor / validate (push) Failing after 5m54s
2026-06-14 11:39:34 +00:00
clemsail fab5cb0bdd feat(plip): land ESP-IDF migration — submodule bump + boards.yaml WiFi-direct IP
Repo State / repo-state (push) Failing after 39s
2026-06-14 13:35:16 +02:00
electron 2462a0c489 Merge pull request 'feat(box3): WiFi-direct file push — boards.yaml IP + /game/file submodule bump' (#143) from feat/box3-wifi-direct into main
Repo State / repo-state (push) Failing after 31s
Validate Zacus refactor / validate (push) Failing after 5m34s
2026-06-14 11:10:33 +00:00
clemsail bce56e121b chore: bump ESP32_ZACUS — box3 /game/file (HTTP-direct asset push)
Repo State / repo-state (pull_request) Failing after 29s
Repo State / repo-state (push) Failing after 39s
Validate Zacus refactor / validate (pull_request) Failing after 6m9s
2026-06-14 13:10:09 +02:00
clemsail f94c18cb4d chore(boards): box3 is WiFi-direct (192.168.0.243), not relay-only 2026-06-14 12:58:56 +02:00
electron c0b11890ec Merge pull request 'feat(atelier): group config / deploy / assets panels → menu bar' (#142) from feat/atelier-panels-menu into main
Repo State / repo-state (push) Failing after 34s
Validate Zacus refactor / validate (push) Failing after 20m10s
2026-06-14 10:46:32 +00:00
clemsail e604bed824 feat(atelier): move group config / deploy / assets panels into the menu bar
Repo State / repo-state (push) Failing after 40s
Repo State / repo-state (pull_request) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 5m38s
2026-06-14 12:14:13 +02:00
electron bdc4a75237 Merge pull request 'chore: bump ESP32_ZACUS — real BOX-3 screen + play' (#141) from chore/bump-box3-screen-play into main
Repo State / repo-state (push) Failing after 43s
Validate Zacus refactor / validate (push) Failing after 6m2s
2026-06-14 10:03:21 +00:00
clemsail bf4f4e9c88 chore: bump ESP32_ZACUS — real BOX-3 screen render + WAV/I2S play
Repo State / repo-state (push) Failing after 1m14s
Validate Zacus refactor / validate (pull_request) Failing after 10m15s
Repo State / repo-state (pull_request) Failing after 10m24s
2026-06-14 12:03:15 +02:00
electron 2a1d964a86 Merge pull request 'feat(gateway): structured D5 {op,a} ESP-NOW relay CMD (closes annex loop)' (#140) from fix/gateway-structured-espnow-cmd into main
Repo State / repo-state (push) Failing after 38s
Validate Zacus refactor / validate (push) Failing after 5m47s
2026-06-14 09:44:48 +00:00
clemsail 939f30cf56 feat(gateway): emit structured D5 {op,a} CMD on ESP-NOW relay (closes annex loop)
Repo State / repo-state (pull_request) Failing after 40s
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (pull_request) Failing after 5m42s
Relay routes (/v1/media/play, /v1/step) now send the structured contract JSON
instead of legacy text, so the box3 cmd_exec executor decodes+dispatches them.
Proven on hardware: relay 'play' → BOX-3 logs cmd_exec op=play + beep.
2026-06-14 11:44:31 +02:00
electron 440b3bab5c Merge pull request 'feat(gateway): scenario file management + staged asset list/delete' (#139) from feat/gateway-file-management into main
Repo State / repo-state (push) Failing after 26s
Validate Zacus refactor / validate (push) Failing after 14m58s
2026-06-14 09:39:24 +00:00
clemsail d45cd3cffd feat(gateway): scenario file management (delete/duplicate/rename) + staged asset list/delete
Repo State / repo-state (pull_request) Failing after 24s
Repo State / repo-state (push) Failing after 31s
Validate Zacus refactor / validate (pull_request) Failing after 5m36s
2026-06-14 11:38:22 +02:00
electron 5f8115ad36 Merge pull request 'chore: bump ESP32_ZACUS — BOX-3 CMD executor' (#138) from chore/bump-box3-firmware into main
Repo State / repo-state (push) Failing after 30s
Validate Zacus refactor / validate (push) Failing after 5m35s
2026-06-14 09:34:55 +00:00
clemsail 91522b4ed7 chore: bump ESP32_ZACUS — BOX-3 ESP-NOW CMD executor (D5)
Repo State / repo-state (pull_request) Failing after 33s
Repo State / repo-state (push) Failing after 25s
Validate Zacus refactor / validate (pull_request) Failing after 5m50s
2026-06-14 11:34:43 +02:00
electron 60a759f4a6 Merge pull request 'feat(atelier): import canonical Runtime3 scenarios into Blockly' (#137) from feat/atelier-runtime3-import into main
Repo State / repo-state (push) Failing after 38s
Validate Zacus refactor / validate (push) Failing after 5m35s
2026-06-14 09:33:15 +00:00
clemsail 2fd528a504 feat(atelier): import canonical Runtime3 scenarios into Blockly (best-effort, with warnings)
Repo State / repo-state (push) Failing after 35s
Repo State / repo-state (pull_request) Failing after 36s
Validate Zacus refactor / validate (pull_request) Failing after 5m46s
2026-06-14 11:31:10 +02:00
electron 13dc5deab0 Merge pull request 'feat(atelier): test on board — arm step / play cue from Deploy tab' (#136) from feat/atelier-test-on-board into main
Repo State / repo-state (push) Failing after 22s
Validate Zacus refactor / validate (push) Failing after 5m46s
2026-06-14 09:16:56 +00:00
electron 377a020e62 Merge pull request 'feat(blockly): onCodeAssembled transition block (D4 completion)' (#135) from feat/dsl-oncodeassembled into main
Repo State / repo-state (push) Failing after 30s
Validate Zacus refactor / validate (push) Failing after 5m34s
2026-06-14 09:16:47 +00:00
clemsail 46eebd0066 feat(blockly): onCodeAssembled transition block (completes D4 puzzle-state branching)
Repo State / repo-state (push) Failing after 34s
Repo State / repo-state (pull_request) Failing after 37s
Validate Zacus refactor / validate (pull_request) Failing after 5m38s
2026-06-14 11:14:31 +02:00
clemsail 48368a1299 feat(atelier): 'test on board' — arm step / play cue from the Deploy tab (D2/D3 endpoints)
Repo State / repo-state (push) Failing after 35s
Repo State / repo-state (pull_request) Failing after 28s
Validate Zacus refactor / validate (pull_request) Failing after 5m32s
2026-06-14 11:12:34 +02:00
electron b22ec686fd Merge pull request 'fix(gateway): resolve media path to absolute /sdcard for playback' (#134) from fix/media-path-sdcard into main
Repo State / repo-state (push) Failing after 29s
Validate Zacus refactor / validate (push) Failing after 5m29s
2026-06-14 08:53:19 +00:00
clemsail 101d1b3364 fix(gateway): resolve media path to absolute /sdcard|/littlefs for /game/media/play
Repo State / repo-state (pull_request) Failing after 38s
Repo State / repo-state (push) Failing after 25s
Validate Zacus refactor / validate (pull_request) Failing after 5m42s
Found via real-hardware E2E: the firmware mis-resolves relative paths (prepends
/sdcard/music/), so staged assets (zacus/<board>/<file>) returned file_not_found.
Resolve client paths to absolute firmware paths before POSTing. Proven on the
real master: relative 'zacus/master/cue.wav' now plays (/sdcard/zacus/master/cue.wav).
2026-06-14 10:53:13 +02:00
electron 127527e5ed Merge pull request 'feat(blockly): DSL réel D3-D5 — média SD, transitions réelles, contrat ESP-NOW' (#133) from feat/blockly-dsl-transitions into main
Repo State / repo-state (push) Failing after 34s
Validate Zacus refactor / validate (push) Failing after 5m34s
2026-06-14 08:10:36 +00:00
clemsail 0d736459f4 docs(esp-now): structured CMD contract for annex cards (D5 roadmap)
Repo State / repo-state (pull_request) Failing after 31s
Repo State / repo-state (push) Failing after 35s
Validate Zacus refactor / validate (pull_request) Failing after 5m37s
2026-06-14 10:10:00 +02:00
clemsail e3b868cbe1 feat(blockly): real event transitions — onButton/onAudioDone/onUnlock/onSerial/onEspnow/onPuzzleSolved (D4) 2026-06-14 10:08:25 +02:00
clemsail 95f9e4fa98 feat(blockly): real SD media — mediaPlay block -> sound_play{path,board,loop} (D3) 2026-06-14 10:02:13 +02:00
electron b8b4fb5f91 Merge pull request 'feat(blockly): board binding (D2) — board field + hwOnBoard, IR + gateway routing' (#132) from feat/blockly-dsl-board-binding into main
Repo State / repo-state (push) Failing after 35s
Validate Zacus refactor / validate (push) Successful in 10m53s
2026-06-14 07:54:43 +00:00
L'électron rare 6d288160af fix(voice): fix transcribe field and block persona injection
Repo State / repo-state (push) Failing after 36s
Validate Zacus refactor / validate (push) Successful in 11m44s
Re-applied against current main (the earlier audit base was stale):

- gateway /voice/transcribe forwarded the multipart file as field
  'audio'; whisper.cpp expects 'file' -> empty transcription through
  the gateway. One-line field rename.
- /voice/intent caller-supplied messages were passed verbatim, letting
  a gateway client inject a 'system' role and override the Zacus
  persona ('never reveal the solution'). Now the canonical persona is
  prepended and caller roles are allow-listed to user/assistant
  (system dropped + logged).

py_compile clean on both. The host voice runtime (studio) is down, so
not runtime-tested.
2026-06-14 02:54:30 +02:00
clemsail 265427a236 feat(gateway): board-aware routing (resolve board, /v1/media/play, /v1/step) for board binding (D2)
Repo State / repo-state (push) Failing after 42s
Repo State / repo-state (pull_request) Failing after 39s
Validate Zacus refactor / validate (pull_request) Successful in 10m58s
2026-06-14 01:44:52 +02:00
clemsail 3ef4fbda53 feat(blockly): board binding — board field on puzzles + hwOnBoard context, compiled+validated to IR (D2) 2026-06-14 01:33:47 +02:00
electron 39b0ec4456 Merge pull request 'feat(blockly): DSL honnête — voies réel / hardware-futur + warning (D1)' (#131) from feat/blockly-dsl-honest into main
Repo State / repo-state (push) Failing after 41s
Validate Zacus refactor / validate (push) Successful in 11m19s
2026-06-13 23:22:12 +00:00
electron 24ab1f2f3e Merge pull request 'feat(atelier): graphe de scénario spatial (React Flow, layout vertical)' (#130) from feat/atelier-flowgraph-spatial into main
Validate Zacus refactor / validate (push) Failing after 10m45s
Repo State / repo-state (push) Failing after 10m51s
2026-06-13 23:21:26 +00:00
clemsail ca80799393 feat(blockly): honest DSL — real vs future-hardware lanes + compile warning (D1)
Repo State / repo-state (push) Failing after 48s
Validate Zacus refactor / validate (pull_request) Failing after 10m22s
Repo State / repo-state (pull_request) Failing after 10m36s
2026-06-14 01:06:36 +02:00
clemsail 9c9a24808a docs(specs): honest hardware-coupled Blockly DSL — design (5 phases) 2026-06-14 01:00:07 +02:00
clemsail 10434d5445 feat(atelier): spatial scenario flow graph (React Flow, vertical auto-layout)
Repo State / repo-state (push) Failing after 48s
Repo State / repo-state (pull_request) Failing after 49s
Validate Zacus refactor / validate (pull_request) Successful in 13m2s
2026-06-14 00:36:34 +02:00
electron 910584328d Merge pull request 'feat(atelier): refonte UI « Workbench » (retrait 3D, thème labo, banc d'essai écran+flux)' (#129) from feat/atelier-refonte-workbench into main
Repo State / repo-state (push) Failing after 13m9s
Validate Zacus refactor / validate (push) Failing after 18m2s
2026-06-13 22:10:07 +00:00
clemsail b99db18225 style(atelier): remove dead mode-tabs CSS (final review)
Repo State / repo-state (push) Failing after 29s
Repo State / repo-state (pull_request) Failing after 33s
Validate Zacus refactor / validate (pull_request) Successful in 11m18s
2026-06-14 00:00:45 +02:00
clemsail ac78f13669 docs(atelier): fix blocks/ description in CLAUDE.md 2026-06-13 23:56:55 +02:00
clemsail 7c7f112b7d refactor(atelier): parse flow graph once in WorkbenchPane, precompute edges (review) 2026-06-13 23:54:59 +02:00
clemsail e0eb00bc97 feat(atelier): Workbench pane — faithful device screen + scenario flow graph 2026-06-13 23:49:03 +02:00
clemsail 9ee377587e fix(atelier): route Workbench theme colours through WB single source (review) 2026-06-13 23:42:34 +02:00
clemsail 432ea0aa1a feat(atelier): Workbench theme (palette firmware, Orbitron/IBM Plex Mono, Blockly theme) 2026-06-13 23:37:53 +02:00
clemsail c6e517f8cf docs(atelier): update CLAUDE.md post-3D-removal (review follow-up) 2026-06-13 23:33:52 +02:00
clemsail f6bb3d3a41 refactor(atelier): remove 3D simulation (StagePane/scene/puzzles/modes/simStore, three deps) 2026-06-13 23:28:53 +02:00
clemsail 808d8bca2a docs(plans): atelier Workbench refonte — implementation plan (3 phases) 2026-06-13 23:22:41 +02:00
clemsail 00f8770ec0 docs(specs): atelier UI refonte — Workbench console (remove 3D, device+flow) 2026-06-13 23:19:30 +02:00
electron f78d649527 Merge pull request 'feat(atelier): asset authoring pipeline P2–P5 (audio, écran LCD, sync SD, atelier unifié)' (#128) from feat/p2-audio-pipeline into main
Repo State / repo-state (push) Failing after 1m15s
Validate Zacus refactor / validate (push) Failing after 12m45s
2026-06-13 20:43:35 +00:00
clemsail 4f4fedfda2 feat(atelier): P5 — unified Assets panel (text/voice/screen/deploy)
Repo State / repo-state (push) Failing after 38s
Repo State / repo-state (pull_request) Failing after 44s
Validate Zacus refactor / validate (pull_request) Successful in 13m20s
Fold the three per-type authoring panels into one tabbed « Assets » workspace:
the floating TextGen/AudioGen/ScreenEditor panels become embeddable *Body
components, and a new Deploy tab stages the generated audio onto a board's SD
via the gateway (POST /v1/assets/stage, P4) with per-asset status. App.tsx now
mounts a single AssetsPanel instead of three floating buttons.

typecheck + vite build clean.
2026-06-13 22:09:30 +02:00
clemsail c5f4de938e feat(gateway): P4 — stage assets onto a board's SD card
POST /v1/assets/stage pushes the staged assets to a board's microSD via the
firmware's existing POST /game/file?path=sd/... route (which already mkdir -p's
and writes under /sdcard) — on-card layout /sdcard/zacus/{board}/<file>. hot
pushes over HTTP to the board IP; cold writes a host bundle + manifest.json for
manual SD copy. No firmware change needed; NFS and a PULL-side /assets/sync stay
hardware-gated.

Validated end-to-end: cold bundles box3; hot pushes to a stub board at the
exact sd/zacus/{board}/ path.
2026-06-13 22:05:09 +02:00
clemsail bc5fe00d58 feat(atelier): P3a — LCD screen (scene) editor with faithful preview
Repo State / repo-state (push) Failing after 48s
Floating « Écran (LCD) » panel: edits a step's scene (title/subtitle/symbol/
effect) with a faithful ST7796 480×320 preview (Workbench palette, Orbitron/
IBM Plex Mono fonts, animated pulse/glitch/gyro effects mirrored from
display_ui.cpp), enforces the firmware char limits (47/63/15), drafts screen
text via ailiance (scene_text), and emits the scene: YAML block to paste onto
a step. Output round-trip-validated against the compiler's _validate_step_scene.

typecheck + vite build clean.
2026-06-13 21:56:32 +02:00
clemsail df11635b0f feat(atelier): P2 — audio (TTS) generation panel
Repo State / repo-state (push) Failing after 52s
Floating « Générer voix » panel mirroring the P1 text-gen one: pick a backend
(f5 cloned Zacus voice via voice-bridge, or kokoro stock voices via vllm-mlx),
a stock voice, type a line, generate, then audition the staged WAV in an inline
<audio> player + download. Async job polling lives in lib/audiogen.ts.

typecheck + vite build clean.
2026-06-13 21:35:33 +02:00
clemsail 3336e2cea2 feat(gateway): P2 — async audio asset generation
Add the audio half of the asset pipeline: an in-process asyncio TTS queue
with POST /v1/assets/audio/generate (backend f5=cloned Zacus voice via the
voice-bridge, or kokoro=stock voices via vllm-mlx /v1/audio/speech), polled
via GET /v1/assets/audio/{id}/status, with generated WAVs staged under
share-store/_staged and previewed read-only via GET /v1/_staged/{key}
(path-traversal guarded). Token-free same-origin, mirroring P1 text-gen.

Validated end-to-end: generate(kokoro, ff_siwis) -> done -> 24 kHz WAV preview.
2026-06-13 21:33:03 +02:00
electron a5ebf7c68c Merge pull request 'feat(atelier): P1 — génération de texte (IA) via ailiance' (#126) from feat/p1-text-gen-ailiance into main
Validate Zacus refactor / validate (push) Successful in 11m0s
Repo State / repo-state (push) Failing after 28s
Validate Zacus refactor / validate (pull_request) Successful in 11m14s
Repo State / repo-state (pull_request) Failing after 12m23s
2026-06-13 14:53:10 +00:00
clement c5db426bec feat(atelier): P1 — génération de texte (IA) via gateway.ailiance.fr
Repo State / repo-state (push) Failing after 33s
Repo State / repo-state (pull_request) Failing after 30s
Validate Zacus refactor / validate (pull_request) Failing after 17m17s
Gateway : POST /v1/assets/text/generate (sans token, même origine) proxie le
LLM ailiance (OpenAI-compatible) avec un system-prompt par type — npc_reply,
hint, scene_text, freeform. Setting ailiance_url (défaut gateway.ailiance.fr).

Atelier : panneau flottant « Générer (IA) » (TextGenPanel) — choix du type,
prompt, génération, aperçu, copier. Client lib/textgen.ts (même URL gateway
que share/deploy, donc même origine en prod → zéro CORS).

Validé : POST .../v1/assets/text/generate → réplique NPC / indice en personnage
(modèle Ministral-14B via ailiance). Bundle prod câblé sur zacus.saillant.cc/api.
Première tranche du plan specs/2026-06-13-atelier-asset-authoring-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:52:45 +02:00
clement e806fc9634 docs(specs): plan phasé — édition & génération d'assets depuis l'Atelier
Repo State / repo-state (push) Failing after 27s
Validate Zacus refactor / validate (push) Successful in 10m55s
Plan de projet (P0–P5) pour éditer/générer écran, audio et fichiers SD/NFS
depuis l'Atelier web, avec ponts firmware et génération assistée
(gateway.ailiance.fr pour le texte). Inclut l'audit du pipeline d'assets
existant, dépendances entre phases, risques, et chemin web/firmware séparés.

NB: la fondation firmware audio (P4 : playback WAV/MP3, montage SD) est
désormais livrée côté ESP32_ZACUS — voir bump submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:46:13 +02:00
clement 3e75632486 chore: bump ESP32_ZACUS — P4 audio (WAV/MP3/SD) + box3 fixes
Repo State / repo-state (push) Failing after 22s
Validate Zacus refactor / validate (push) Successful in 11m8s
Avance le submodule ESP32_ZACUS vers main (7f52d3d), qui intègre :
- #8 box3 : SPIFFS label + canal WiFi configurable (relais ESP-NOW).
- #9 P4 : media_manager vrai playback WAV/MP3 (helix), endpoint
  /game/media/play, montage microSD /sdcard, branchement media+/game/file
  sur la SD, mutex I2S TTS↔musique, FATFS LFN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:45:37 +02:00
clement b76f2d3c98 fix(scenario): zacus_v3 valide — act/objective/firmware_step sur les 3 puzzles
Repo State / repo-state (push) Failing after 12m35s
Validate Zacus refactor / validate (push) Successful in 17m26s
Les puzzles de zacus_v3.yaml n'avaient ni `act`, ni `objective`, ni
`firmware_step` (requis par le validateur schema V2). Ajout calqué sur
zacus_v2 : Diapason→acte 1, Piano LEFOU→acte 2, QR Archives→acte 2 ;
valeurs déduites du contenu existant (scène, runtime_steps, solution).
Le scénario valide, compile et simule désormais le playthrough complet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:07:45 +02:00
electron 54b8d2a9f9 Merge pull request 'Atelier : barre de menu — ouvrir/charger/enregistrer/partager' (#125) from feat/atelier-menubar-share into main
Repo State / repo-state (push) Failing after 25s
Validate Zacus refactor / validate (push) Successful in 11m29s
2026-06-13 10:58:36 +00:00
clement b05264aafe feat(atelier): barre de menu — ouvrir/charger/enregistrer/partager un scénario
Repo State / repo-state (push) Failing after 41s
Repo State / repo-state (pull_request) Failing after 6m58s
Validate Zacus refactor / validate (pull_request) Successful in 11m13s
Ajoute une barre de menu en haut de l'Atelier :
- Ouvrir : menu déroulant de 3 scénarios de départ embarqués (format
  blocks_studio v2) — intro+énigme sonore, maître du jeu adaptatif,
  énigmes→coffre.
- Charger / Enregistrer : import depuis / export vers un fichier .yaml local.
- Partager : publie le scénario sur la gateway et copie un lien court ?s= ;
  useShareLink réimporte automatiquement le scénario au chargement du lien.

Gateway : endpoints publics sans token POST/GET /v1/share (taille plafonnée,
YAML validé), adossés à un store fichier (share-store/).

Corrige aussi l'inlining de VITE_GATEWAY_BASE_URL : accès statique
import.meta.env pour que le build prod vise la vraie gateway
(zacus.saillant.cc/api) — ce qui répare au passage le DeployPanel en prod.
Le build web prod utilise désormais un mode Vite dédié `webdeploy`
(.env.webdeploy) pour ne pas polluer le bundle desktop.

Ajoute ops/deploy-frontend.sh (typecheck → build → tar-over-ssh → reload →
vérif du hash live), le déploiement n'étant pas scripté jusqu'ici.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:53:57 +02:00
electron 2ecad365d1 Merge pull request 'Atelier: blocs Scratch complets (41 to 68) + runner Runtime 3' (#124) from feat/atelier-scratch-blocks into main
Repo State / repo-state (push) Failing after 30s
Validate Zacus refactor / validate (push) Successful in 10m58s
2026-06-13 00:18:20 +00:00
electron 6dd78b3a69 feat(atelier): vagues 3+4 — énigmes manquantes + assemblage code/coffre
Repo State / repo-state (push) Failing after 23s
Repo State / repo-state (pull_request) Failing after 33s
Validate Zacus refactor / validate (pull_request) Successful in 11m37s
Vague 3 (énigmes, catalogue 59→68) : puzzleLED (P2), puzzleRadio (P4),
puzzleMorse (P5), puzzleNFC (P6), puzzleCoffre/keypad (P7), + hwNFCRead.
Types puzzle IR: led, radio, morse, nfc, keypad (validation firmware-miroir).

Vague 4 (catégorie Coffre) : codeAssembly (fragments→code final),
codeFragment (puzzle→positions), coffreVerify. Kinds IR: nfc_read,
code_assembly, code_fragment, coffre_verify.

Le scénario zacus_v3_complete est désormais entièrement exprimable en blocs.

Validé: catalogue 68/68 cohérent, build atelier OK, round-trip gateway +
validation runtime3 (happy path + négatifs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 02:15:05 +02:00
electron 1f524a1ed7 feat(scenario-engine): runner d'exécution Runtime 3 (eval + loop + pilotage firmware)
Repo State / repo-state (push) Failing after 56s
Rend l'IR réellement jouable (avant: simulateur partiel, conditions opaques) :
- expr-eval.ts: parser réel (tokeniser + descente récursive, sans eval())
  pour la grammaire des conditions (comparaisons, and/or/not, arith, vars)
- runner.ts: exécute loop (repeat/while bornés), condition then/else,
  transitions branch/condition triées par priorité
- firmware-driver.ts: pilotage live du master via HTTP (pushScenario
  /game/scenario, armStep /game/step, getPuzzleState, setGroupProfile),
  fetch injecté pour testabilité
- runtime-ir.ts: types TS miroir de runtime3_common.py

Validé: 55 tests (18 existants + 37 nouveaux), build tsc strict OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:57:41 +02:00
electron a85f39ba9a feat(atelier): vague 2 — blocs « Maître du jeu » (game-master adaptatif)
8 blocs pour la couche adaptative du scénario (catalogue 51→59) :
phase, gmRule (condition→proactive_hint/skip_to_next/add_bonus_puzzle),
npcSetMood, giveHint (niveau), proactiveHint (intervalle), setParcours
(profil→puzzles), scoreBonus (conditionnel), scorePenalty.
gmRule/scoreBonus réutilisent les reporters de la vague 1.
Nouveaux kinds IR: gm_phase, gm_rule, npc_mood, give_hint, proactive_hint,
set_parcours, score_bonus, score_penalty (+ validation runtime3_common).

Validé: catalogue 59/59 cohérent, build atelier OK, round-trip gateway OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:57:41 +02:00
electron 4d169bb990 feat(atelier): vague 1 — blocs Scratch opérateurs/expressions + contrôle
Repo State / repo-state (push) Failing after 43s
Ajoute 10 blocs pour rendre les conditions composables (avant: chaînes
opaques tapées à la main) :
- Reporters: opCompare, opBool, opNot, opArith, valVar, valScore,
  valNumber, valText → sérialisés vers la grammaire d'expression existante
  (ex: "score > 5", "(pct >= 60) and (lives > 0)")
- Contrôle: loopRepeat, loopWhile (nouveau kind IR "loop")
- logicIf/sceneBranch acceptent un reporter branché OU le champ texte
  (rétrocompat totale)

Catalogue 41→51 blocs, catégories Opérateurs + Contrôle. Compilateur
gateway (_reporter_to_expr) miroir du serializeReporter() TS. Hub
Blockly régénéré.

Validé: build atelier OK, 18 tests scenario-engine, round-trip
bloc→IR (reporters + loops + rétrocompat).

NB: le kind IR "loop" exige un support firmware runtime3 (à ajouter
dans une passe firmware) ; les conditions sont déjà évaluées par le
firmware existant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:41:24 +02:00
electron fcba0cd310 Merge pull request 'Merge hotload-hub: atelier scene/puzzle blocks + firmware ESP-NOW (sauvegarde claude2)' (#123) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 24s
Validate Zacus refactor / validate (push) Successful in 10m53s
2026-06-12 22:57:15 +00:00
electron b0fc2769d7 docs(gateway): marquer flash_bridge.sh obsolète (collision réseau résolue)
Repo State / repo-state (push) Failing after 28s
Validate Zacus refactor / validate (push) Successful in 11m0s
Diagnostic réseau electron-server (2026-06-12) : la collision 192.168.0.0/20
qui justifiait le contournement n'existe plus. Les ESP sont en LAN 10.2.5.x,
aucun réseau Docker ne capture 10.2.x, et le conteneur gateway (réseau traefik)
route vers 10.2.x via l'hôte. /v1/flash/{board} doit marcher en direct quand la
carte est sous tension. À confirmer par un flash réel puis supprimer le script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:01:37 +02:00
electron fb860238b3 docs: corriger incohérences README + boards.yaml (audit liaison webapp↔firmware)
Repo State / repo-state (push) Failing after 35s
Validate Zacus refactor / validate (push) Successful in 11m32s
Le README et boards.yaml pointaient un canon périmé. Réalité (audit 2026-06-12) :
- canon = frontend-v3/apps/atelier (pnpm+turbo) + ESP32_ZACUS/idf_zacus (ESP-IDF)
  + gateway tools/zacus-gateway (:8400) + scénario zacus_v3_complete.yaml
- frontend-scratch-v2 et hardware/firmware (Arduino freenove) = legacy
- boards.yaml: /game/scenario EST implémenté (game_endpoint.c), commentaire 502 obsolète

Corrections documentaires uniquement, aucun changement de code/comportement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:37:01 +02:00
Claude Worker claude2 2779dc892a chore: bump ESP32_ZACUS (canal espnow CMD/EVT)
Repo State / repo-state (push) Failing after 23s
Repo State / repo-state (pull_request) Failing after 35s
Validate Zacus refactor / validate (pull_request) Successful in 10m55s
2026-06-11 05:47:20 +02:00
Claude Worker claude2 7dc42b7965 docs: spec ESP-NOW commandes/evenements v1 2026-06-11 05:41:10 +02:00
Claude Worker claude2 ac2fa5c2b0 chore: bump ESP32_ZACUS (espnow peers + UAF) 2026-06-11 05:18:55 +02:00
Claude Worker claude2 62eeaa016d test(blocks): export des 4 blocs ESP-NOW
Couvre le mapping blocs->actions IR (register_peer/send/broadcast/
wait + timeout_ms), le defaut 10 s, et la legalite en slot logicIf.
2026-06-11 05:09:05 +02:00
Claude Worker claude2 083bf5a35a chore: bump ESP32_ZACUS (fix horloge intro) 2026-06-11 05:03:04 +02:00
Claude Worker claude2 5f2ad757d4 chore: bump ESP32_ZACUS (pool FX 3D complet) 2026-06-11 04:54:17 +02:00
Claude Worker claude2 c7068380f3 chore: bump ESP32_ZACUS (FX 3D + log P1) 2026-06-11 04:47:53 +02:00
Claude Worker claude2 6e837cc195 merge: ir-puzzle-scene (IR puzzle/scene + blocs) 2026-06-11 04:29:39 +02:00
Claude Worker claude2 a481f130d6 fix(scenario): straight quotes in zacus_v3
Les guillemets typographiques servaient de pseudo-délimiteurs YAML :
les valeurs chargées contenaient littéralement “…” (endpoints LLM/TTS,
names des puzzles) et la séquence du piano valait [“L”,“E”,…] au lieu
de [L,E,F,O,U]. Conversion “”→", ‘’→', …→... ; tirets et « » français
conservés. Parse vérifié avant/après (yaml.safe_load).
2026-06-11 03:37:08 +02:00
Claude Worker claude2 27544fecd1 feat(blocks): scene/puzzle blocks in atelier
Catégorie « Énigmes » dans le catalogue Blockly canonique :
zacus_scene (écran maître title/subtitle/symbol/effect),
zacus_puzzleQR et zacus_puzzleSound. L'export blocks_to_runtime3
les attache au step courant comme objets IR scene/puzzle (pas des
actions), avec clamping/troncature vers les limites strictes du
gateway pour que l'IR émis valide toujours. Copie hub régénérée
via sync_blockly_catalog.sh. 14 tests d'export ajoutés (60 verts).
2026-06-11 03:34:21 +02:00
Claude Worker claude2 b1486b27ff feat(ir): compile solution blocks to puzzles 2026-06-11 03:14:20 +02:00
Claude Worker claude2 dd6df1fa69 docs(ir): example IR + authoring gap note 2026-06-11 03:07:14 +02:00
Claude Worker claude2 7086411ea3 chore: bump ESP32_ZACUS (top README) 2026-06-11 02:58:10 +02:00
Claude Worker claude2 f81601bebd chore: bump ESP32_ZACUS (docs+qr fixes) 2026-06-11 02:57:05 +02:00
Claude Worker claude2 f71a69e4b7 chore: bump ESP32_ZACUS (box3 stimulus) 2026-06-10 23:15:48 +02:00
Claude Worker claude2 261369d50b chore: bump ESP32_ZACUS (puzzles+UI port) 2026-06-10 23:07:00 +02:00
Claude Worker claude2 7e690f5a20 feat(ir): step puzzle + scene fields 2026-06-10 22:57:14 +02:00
Claude Worker claude2 9e78dd7b6b chore: ignore .worktrees/ 2026-06-10 22:52:22 +02:00
Claude Worker claude2 a4838dccf0 docs: plan impl enigmes locales Freenove
Repo State / repo-state (push) Failing after 13m46s
2026-06-10 13:42:56 +02:00
Claude Worker claude2 7ee051827f docs: spec enigmes locales Freenove P1/P3
Repo State / repo-state (push) Failing after 1m7s
2026-06-10 13:34:39 +02:00
Claude Worker claude2 19bce1075a chore: bump ESP32_ZACUS (BOX-3 BSP fix)
Repo State / repo-state (push) Failing after 3m20s
2026-06-10 13:22:03 +02:00
electron 4ae92abcb3 Merge pull request 'feat(hw): projet KiCad du téléphone PLIP' (#122) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 33s
Validate Zacus refactor / validate (push) Successful in 15m20s
2026-06-10 10:55:08 +00:00
Claude Worker claude2 e1a116d0f5 feat(hw): PLIP telephone KiCad project
Repo State / repo-state (push) Failing after 48s
Validate Zacus refactor / validate (pull_request) Successful in 11m10s
Repo State / repo-state (pull_request) Failing after 12m5s
Custom ESP32-WROOM-32E + Si3210 SLIC board replacing the ESP32-A1S
dev kit (final PLIP target, RJ9 handset). Generated via tools/
gen_si3210_lib.py (symbol + QFN-38 footprint), gen_schematic.py
(parses standard KiCad libs, 45 instances, 157 global labels on pin
ends) and gen_pcb.py (58x38mm outline, 41 footprints placed per the
plan, nets assigned, unrouted).

ERC: 0 violations. Schematic follows the spec GPIO map and the plan's
passive network. PCB placed not routed (Freerouting + DRC + gerbers
remain — see README). BOM in JLCPCB format; CP2102N/transformer/RJ9
LCSC parts marked TBD.

WARNINGS (README): the Si3210 -72V DC-DC stage is simplified and must
be checked against the Skyworks datasheet/AN35 before fab; line
protection is minimal; human review required before ordering.
2026-06-10 12:52:43 +02:00
electron ec7fb2bac2 Merge pull request 'chore: bump ESP32_ZACUS (téléphone écran BOX-3)' (#121) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 11m48s
Validate Zacus refactor / validate (push) Failing after 14m35s
2026-06-10 10:45:44 +00:00
Claude Worker claude2 8b9151851f chore: bump ESP32_ZACUS (BOX-3 on-screen phone)
Repo State / repo-state (push) Failing after 43s
Validate Zacus refactor / validate (pull_request) Failing after 14m28s
Repo State / repo-state (pull_request) Failing after 14m48s
Submodule points at the merged on-screen phone + touch keypad + hook
button (PR ESP32_ZACUS#6), flashed and verified on hardware.
2026-06-10 12:44:42 +02:00
electron 456be02133 Merge pull request 'chore: bump ESP32_ZACUS (PLIP virtuel sur BOX-3)' (#120) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 40s
Validate Zacus refactor / validate (push) Failing after 11m7s
2026-06-10 10:30:59 +00:00
Claude Worker claude2 f5328d9d74 chore: bump ESP32_ZACUS (virtual PLIP on BOX-3)
Repo State / repo-state (push) Failing after 43s
Repo State / repo-state (pull_request) Failing after 12m46s
Validate Zacus refactor / validate (pull_request) Successful in 13m39s
Submodule points at the merged phone-less PLIP annex
(PR ESP32_ZACUS#5): PLIP REST contract + speaker ring + BOOT hook
switch + /voice/hook reporting on the BOX-3.
2026-06-10 12:30:30 +02:00
electron f36eac67a2 Merge pull request 'feat: journal sim en console + rate/emotion TTS + peers HTTP' (#119) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 47s
Validate Zacus refactor / validate (push) Failing after 12m54s
2026-06-10 09:57:59 +00:00
Claude Worker claude2 db63da7d2e feat: sim log in console + TTS rate/emotion
Repo State / repo-state (pull_request) Failing after 43s
Repo State / repo-state (push) Failing after 38s
Validate Zacus refactor / validate (pull_request) Failing after 21m48s
- ConsolePane shows the ScenePlayer narration (simLog, green) under
  the validation entries, plus a current-scene badge in the header.
- voice-bridge accepts rate + emotion on /tts: rate (clamped 0.5..2.0)
  drives the F5 sampler speed; emotion is accepted and cache-keyed but
  not rendered yet (no F5 emotion control). Legacy cache keys are
  preserved when both fields are absent/default — the NPC pool
  generator contract is untouched.
- Bump ESP32_ZACUS: GET/POST /game/peers (PR ESP32_ZACUS#4) — PLIP
  provisioning by curl, live registration without reboot.

Verified: py_compile OK, tsc clean, vite build green, master firmware
build green.
2026-06-10 11:56:25 +02:00
electron b84fb19c86 Merge pull request 'feat(atelier): lecteur de scènes pour la simulation 3D' (#118) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 47s
Validate Zacus refactor / validate (push) Failing after 12m51s
2026-06-10 09:44:51 +00:00
Claude Worker claude2 34b7c3144a feat(atelier): scene player for the 3D stage sim
Repo State / repo-state (push) Failing after 11m12s
Repo State / repo-state (pull_request) Failing after 10m58s
Validate Zacus refactor / validate (pull_request) Successful in 11m42s
The Run button fed blocks_studio v2 YAML to the legacy gameplay engine,
which only parses old zacus_v3 scenarios. loadScenario now tries the v2
document first and plays it through a new ScenePlayer: « Zacus dit »
drives the NPC bubble + Web Speech (rate applied, emotion mapped to the
stage mood), timers wait, goto/branch/intent jump between scenes,
hardware actions are narrated into a capped simLog. Legacy YAML still
falls back to ZacusScenarioEngine. Visualization fidelity only — the
authoritative compiler remains blocks_to_runtime3.py.

Verified: tsc clean, vite build green.
2026-06-10 11:43:57 +02:00
electron c325aee8c2 Merge pull request 'feat(atelier): toolbox scènes Runtime 3 partagée (start/end + voix)' (#117) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 1m2s
Validate Zacus refactor / validate (push) Successful in 11m25s
2026-06-10 09:36:47 +00:00
Claude Worker claude2 089cea409e feat(atelier): Runtime 3 scene toolbox, shared
Repo State / repo-state (push) Failing after 49s
Repo State / repo-state (pull_request) Failing after 1m0s
Validate Zacus refactor / validate (pull_request) Successful in 11m31s
The Atelier exposed a legacy 12-block gameplay-config language whose
export the gateway could not compile (no blocks_studio_version) — and
no start/end blocks. It now speaks the same language as the zacus-hub
native Studio: the 38-block scene catalog (sceneStart/sceneEnd, npcSay
voice lines, HW/ESP-NOW/M5/PLIP, logic) lives once in
@zacus/shared/blockly; the hub copy is GENERATED via
tools/dev/sync_blockly_catalog.sh.

- Export/import rewritten to blocks_studio_version: 2 (flat nodes,
  next pointers, logicIf slots) via shared blocks-doc.ts — what
  POST /v1/flash compiles to Runtime 3 IR, and what the hub Studio
  reads/writes (full interop).
- « Zacus dit » gains voice (voice-bridge voice_ref), emotion and rate
  fields; the compiler forwards them as optional tts_say fields
  (omitted at defaults, backward compatible).
- Editor validation now mirrors compiler rules: missing sceneStart,
  duplicate scene ids, unknown goto targets, empty voice lines.

Verified: tsc clean, vite build green, v2->IR round-trip OK
(entry STEP_intro, tts_say{voice_ref,emotion,rate}).
2026-06-10 11:35:24 +02:00
electron 6ddd0d872d Merge pull request 'chore: bump ESP32_ZACUS (dédup scenario_mesh)' (#116) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 14m41s
Validate Zacus refactor / validate (push) Failing after 19m35s
2026-06-10 08:53:35 +00:00
Claude Worker claude2 102ee99729 chore: bump ESP32_ZACUS (scenario_mesh dedup)
Repo State / repo-state (push) Failing after 47s
Repo State / repo-state (pull_request) Failing after 33s
Validate Zacus refactor / validate (pull_request) Successful in 11m26s
Submodule now points at the merged scenario_mesh hoist (single copy
in lib/, EXTRA_COMPONENT_DIRS) + sdkconfig.old cleanup.
2026-06-10 10:52:37 +02:00
electron 57dd1fcd5d Merge pull request 'feat(atelier): bouton Déployer avec sélecteur de carte' (#115) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 47s
Validate Zacus refactor / validate (push) Successful in 11m30s
2026-06-10 08:42:09 +00:00
Claude Worker claude2 6ad17ab3ec feat(atelier): deploy button with board selector
Repo State / repo-state (pull_request) Failing after 10m11s
Repo State / repo-state (push) Failing after 11m11s
Validate Zacus refactor / validate (pull_request) Successful in 11m31s
DeployPanel + useDeploy hook: saves the current Blockly workspace YAML
to the gateway (PUT /v1/studio/scenario/atelier_live.yaml) then flashes
the selected board (POST /v1/flash/{board}). Boards without an IP that
the master relays over ESP-NOW (plip, box3, p7_coffre) are flagged in
the selector and routed through the master automatically; the panel
shows the FlashResult steps and relay fan-out. Gateway gains a
CORSMiddleware (ZACUS_HUB_CORS_ORIGINS, default *) so the browser
client can reach it — auth stays on the bearer token
(VITE_GATEWAY_TOKEN / VITE_GATEWAY_BASE_URL).

Verified: tsc --noEmit clean, vite build green, py_compile OK.
2026-06-10 10:41:01 +02:00
electron 611b4e38c8 Merge pull request 'feat(plip): scenario hot-load via ESP-NOW' (#114) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 37s
Validate Zacus refactor / validate (push) Failing after 13m31s
2026-06-10 08:32:11 +00:00
Claude Worker claude2 c1afa65c55 chore: bump ESP32_ZACUS (PLIP doc re-scope)
Repo State / repo-state (pull_request) Failing after 1m27s
Repo State / repo-state (push) Failing after 11m31s
Validate Zacus refactor / validate (pull_request) Successful in 12m18s
Submodule now points at the merged doc update marking PLIP as in
scope for the scenario hot-load receiver.
2026-06-10 10:26:44 +02:00
Claude Worker claude2 e7a974e1be feat(plip): scenario hot-load via ESP-NOW
PLIP was out of scope by design (Wi-Fi/HTTP-only); brought back in by
request. New src/scenario_now.{h,cpp}: owns the single esp_now recv
callback (no MSG_* demux needed — PLIP has no legacy ESP-NOW traffic),
reassembles scenario_mesh frames ({seq:u16 LE, total:u16 LE} + <=236 B
payload, <=64 KiB, 5 s sender-silence timeout) in a worker task, then
persists to LittleFS /scenario.json (temp-then-rename). Optional
consumer hook for a future Runtime 3 engine; stored-and-logged without
one. network_task calls scenario_now_init() once the station is up
(idempotent, repeated on reconnect).

Build green: pio run (devkit_es8388), RAM 14.8%, Flash 63.1%.
2026-06-10 10:24:01 +02:00
electron bf1113c5ec Merge pull request 'feat(hub): live game-master state + firmware hot-load submodule' (#113) from feat/zacus-hotload-hub into main
Repo State / repo-state (push) Failing after 51s
Validate Zacus refactor / validate (push) Successful in 11m14s
2026-06-09 05:44:34 +00:00
Claude Worker claude2 26969640a3 feat(hub): live game-master state + bump firmware submodule
Repo State / repo-state (push) Failing after 42s
Repo State / repo-state (pull_request) Failing after 14m5s
Validate Zacus refactor / validate (pull_request) Successful in 12m54s
Gateway (tools/zacus-gateway):
- /v1/state now aggregates live per-group progress from the hints engine
  /hints/sessions (the only backend exposing game state), with graceful
  fallback when the engine is offline or admin-protected. Flows through both
  the REST endpoint and the WS push (shared build_state). Documents
  HINTS_ADMIN_KEY.

App (apps/zacus-hub):
- GameMaster "Groupes en cours" section: active groups, hint/penalty counts,
  current puzzle, and a state_detail note. SessionSnapshot DTO + extended
  GameState.

Submodule:
- ESP32_ZACUS -> feat/idf-migration b2267f2 (ESP-NOW scenario hot-load
  receivers; three firmwares build green under ESP-IDF 5.4.4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:11:45 +02:00
160 changed files with 39022 additions and 2576 deletions
+16
View File
@@ -110,3 +110,19 @@ secrets.json
# Tailscale auth-keys / OAuth client files
tailscale-*.key
.worktrees/
# Build-time public config (committed, no secret): gateway URL for the prod web deploy
!frontend-v3/apps/*/.env.webdeploy
# Generated PLIP SD voice pack (reproducible via tools/tts/generate_plip_sd_pack.py)
tools/tts/plip_sd_pack/
# Generated gamebook WAV pack (reproducible via tools/gamebook/build_gamebook.py)
tools/gamebook/build/
# Generated PLIP phone gamebook pack (reproducible)
tools/gamebook/build_phone/
# Generated master gamebook narration pack (GAMEBOOK_OUT_DIR override)
tools/gamebook/build_master/
+1
View File
@@ -23,6 +23,7 @@ pio device monitor # serial @ 115200
| `src/phone_task.cpp` | Off-hook GPIO interrupt + ring control |
| `src/audio_task.cpp` | ES8388 / Si3210 audio routing, drains a command queue |
| `src/network_task.cpp` | WiFi station + REST server (`/ring`, `/play`, `/stop`, `/status`) |
| `src/scenario_now.cpp` | ESP-NOW scenario hot-load receiver → LittleFS `/scenario.json` |
## Anti-patterns
+6
View File
@@ -23,6 +23,8 @@
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "scenario_now.h"
namespace {
constexpr char kFallbackSsid[] = "ZACUS-SETUP";
@@ -109,6 +111,9 @@ void network_task(void *) {
const WifiCreds creds = load_credentials();
if (connect_wifi(creds)) {
start_mdns_and_probe_master();
// Scenario hot-load receiver (ESP-NOW rides the station's channel).
// Idempotent; safe to call again from the reconnect path below.
scenario_now_init();
} else {
Serial.println(F("[net] proceeding offline — will retry every 5 s"));
}
@@ -125,6 +130,7 @@ void network_task(void *) {
const WifiCreds fresh = load_credentials();
if (connect_wifi(fresh)) {
start_mdns_and_probe_master();
scenario_now_init();
}
}
}
+260
View File
@@ -0,0 +1,260 @@
// scenario_now — implementation. See scenario_now.h for the design notes.
//
// Threading: the esp_now recv callback runs in the Wi-Fi task on the
// Arduino core; it only copies the frame onto a queue. Reassembly, the
// LittleFS write and the consumer callback all run in the worker task.
//
// Failure policy mirrors the puzzle nodes' shared receiver
// (ESP32_ZACUS/lib/espnow_common/espnow_slave.c): bounded heap reassembly
// (<=64 KiB), 5 s sender-silence timeout, a new first frame (seq==0) from a
// different sender preempts a stale transfer.
#include "scenario_now.h"
#include <Arduino.h>
#include <LittleFS.h>
#include <WiFi.h>
#include <esp_now.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <stdlib.h>
#include <string.h>
#ifndef SCENARIO_NOW_QUEUE_DEPTH
#define SCENARIO_NOW_QUEUE_DEPTH 16
#endif
namespace {
constexpr char kTag[] = "[scenario-now]";
constexpr char kPath[] = "/scenario.json";
constexpr char kTmpPath[] = "/scenario.json.tmp";
// Wire format constants — must match scenario_mesh / espnow_slave.c.
constexpr size_t kPayloadMax = 236; // 240 ESP-NOW max - 4 header
constexpr size_t kMaxBytes = 64 * 1024; // master-side cap
constexpr uint16_t kMaxFrames = (kMaxBytes + kPayloadMax - 1) / kPayloadMax;
constexpr uint32_t kTimeoutMs = 5000;
struct FrameItem {
uint8_t mac[6];
uint8_t data[250];
int len;
};
struct Reassembly {
bool active;
uint8_t src[6];
uint16_t total; // expected frame count
uint16_t count; // distinct frames received
size_t tail; // highest payload end offset = reassembled length
uint8_t *buf; // heap, total * kPayloadMax
uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes
uint32_t last_ms;
};
QueueHandle_t g_queue = nullptr;
Reassembly g_rx = {};
scenario_now_callback_t g_cb = nullptr;
bool g_fs_ok = false;
void rx_reset() {
free(g_rx.buf);
free(g_rx.seen);
memset(&g_rx, 0, sizeof(g_rx));
}
// Persist atomically: write the temp file, then rename over the live path.
// A reset mid-write leaves at worst a stale .tmp, never a torn scenario.
bool persist(const uint8_t *json, size_t len) {
if (!g_fs_ok) {
Serial.printf("%s LittleFS unavailable, scenario not persisted\n", kTag);
return false;
}
File f = LittleFS.open(kTmpPath, "w");
if (!f) {
Serial.printf("%s open %s failed\n", kTag, kTmpPath);
return false;
}
const size_t written = f.write(json, len);
f.close();
if (written != len) {
Serial.printf("%s short write %u/%u to %s\n",
kTag, (unsigned)written, (unsigned)len, kTmpPath);
LittleFS.remove(kTmpPath);
return false;
}
LittleFS.remove(kPath); // rename does not overwrite on LittleFS
if (!LittleFS.rename(kTmpPath, kPath)) {
Serial.printf("%s rename %s -> %s failed\n", kTag, kTmpPath, kPath);
return false;
}
return true;
}
void feed(const FrameItem &it) {
if (it.len < 4) return;
const uint16_t seq = (uint16_t)it.data[0] | ((uint16_t)it.data[1] << 8);
const uint16_t total = (uint16_t)it.data[2] | ((uint16_t)it.data[3] << 8);
const uint8_t *payload = it.data + 4;
int payload_len = it.len - 4;
// Begin (or restart from a new sender) on a first frame.
if (seq == 0 && (!g_rx.active || memcmp(it.mac, g_rx.src, 6) != 0)) {
rx_reset();
if (total == 0 || total > kMaxFrames) {
Serial.printf("%s bad/oversized total %u — dropped\n", kTag, total);
return;
}
g_rx.buf = (uint8_t *)calloc(total, kPayloadMax);
g_rx.seen = (uint8_t *)calloc((total + 7) / 8, 1);
if (!g_rx.buf || !g_rx.seen) {
Serial.printf("%s OOM for %u frames — dropped\n", kTag, total);
rx_reset();
return;
}
g_rx.active = true;
g_rx.total = total;
memcpy(g_rx.src, it.mac, 6);
}
if (!g_rx.active) return; // stray frame, no session
if (memcmp(it.mac, g_rx.src, 6) != 0) return; // other sender mid-transfer
if (total != g_rx.total) return; // inconsistent header
if (seq >= g_rx.total) return;
if (payload_len > (int)kPayloadMax) payload_len = kPayloadMax;
g_rx.last_ms = millis();
if (!(g_rx.seen[seq / 8] & (1u << (seq % 8)))) {
const size_t off = (size_t)seq * kPayloadMax;
memcpy(g_rx.buf + off, payload, (size_t)payload_len);
g_rx.seen[seq / 8] |= (uint8_t)(1u << (seq % 8));
g_rx.count++;
const size_t end = off + (size_t)payload_len;
if (end > g_rx.tail) g_rx.tail = end;
}
if (g_rx.count != g_rx.total) return;
// Complete. Cheap sanity check before touching flash: an IR is a JSON
// object, so the first non-whitespace byte must be '{'.
size_t i = 0;
while (i < g_rx.tail && isspace(g_rx.buf[i])) i++;
if (i == g_rx.tail || g_rx.buf[i] != '{') {
Serial.printf("%s reassembled %u bytes but not JSON — dropped\n",
kTag, (unsigned)g_rx.tail);
rx_reset();
return;
}
Serial.printf("%s scenario reassembled: %u frames, %u bytes from "
"%02X:%02X:%02X:%02X:%02X:%02X\n",
kTag, g_rx.total, (unsigned)g_rx.tail,
g_rx.src[0], g_rx.src[1], g_rx.src[2],
g_rx.src[3], g_rx.src[4], g_rx.src[5]);
if (persist(g_rx.buf, g_rx.tail)) {
Serial.printf("%s stored at %s\n", kTag, kPath);
}
if (g_cb) {
g_cb(g_rx.src, (const char *)g_rx.buf, g_rx.tail);
} else {
Serial.printf("%s no consumer registered — stored only\n", kTag);
}
rx_reset();
}
void on_recv(const uint8_t *mac, const uint8_t *data, int len) {
FrameItem item;
memcpy(item.mac, mac, 6);
const int copy_len = len < (int)sizeof(item.data) ? len : (int)sizeof(item.data);
memcpy(item.data, data, copy_len);
item.len = copy_len;
// Non-blocking: under burst the frame is lost and the 5 s timeout (or the
// master's next relay attempt) recovers — same policy as the puzzle nodes.
xQueueSend(g_queue, &item, 0);
}
void worker_task(void *) {
Serial.printf("%s worker ready, storage=%s\n", kTag, kPath);
FrameItem item;
for (;;) {
// Wake at least once a second so the sender-silence timeout fires even
// with an empty queue.
if (xQueueReceive(g_queue, &item, pdMS_TO_TICKS(1000)) == pdTRUE) {
feed(item);
}
if (g_rx.active && (millis() - g_rx.last_ms) > kTimeoutMs) {
Serial.printf("%s rx timeout (%u/%u frames) — dropped\n",
kTag, g_rx.count, g_rx.total);
rx_reset();
}
}
}
} // namespace
bool scenario_now_init() {
if (g_queue != nullptr) return true; // idempotent — reconnect path safe
// network_task owns Wi-Fi; esp_now_init() requires the stack started.
if (WiFi.getMode() == WIFI_MODE_NULL) {
Serial.printf("%s init before Wi-Fi start — call after network_task is up\n",
kTag);
return false;
}
g_fs_ok = LittleFS.begin(true /* format on first mount */);
if (!g_fs_ok) {
Serial.printf("%s LittleFS mount failed — receiver runs without storage\n",
kTag);
} else if (LittleFS.exists(kPath)) {
File f = LittleFS.open(kPath, "r");
Serial.printf("%s existing scenario on flash: %u bytes\n",
kTag, f ? (unsigned)f.size() : 0u);
if (f) f.close();
}
g_queue = xQueueCreate(SCENARIO_NOW_QUEUE_DEPTH, sizeof(FrameItem));
if (g_queue == nullptr) {
Serial.printf("%s xQueueCreate failed\n", kTag);
return false;
}
if (esp_now_init() != ESP_OK) {
Serial.printf("%s esp_now_init failed\n", kTag);
vQueueDelete(g_queue);
g_queue = nullptr;
return false;
}
if (esp_now_register_recv_cb(on_recv) != ESP_OK) {
Serial.printf("%s esp_now_register_recv_cb failed\n", kTag);
esp_now_deinit();
vQueueDelete(g_queue);
g_queue = nullptr;
return false;
}
if (xTaskCreate(worker_task, "scenario-now", 6144, nullptr, 4, nullptr)
!= pdPASS) {
Serial.printf("%s xTaskCreate failed\n", kTag);
esp_now_unregister_recv_cb();
esp_now_deinit();
vQueueDelete(g_queue);
g_queue = nullptr;
return false;
}
Serial.printf("%s ESP-NOW receiver up (channel follows the AP)\n", kTag);
return true;
}
void scenario_now_register_callback(scenario_now_callback_t cb) {
g_cb = cb;
}
const char *scenario_now_storage_path() {
return kPath;
}
+48
View File
@@ -0,0 +1,48 @@
// scenario_now — ESP-NOW scenario hot-load receiver for PLIP.
//
// Brings PLIP into the scope of the firmware scenario hot-load
// (docs/specs/2026-05-24-firmware-scenario-hotload.md, task 6). The Zacus
// master relays a Runtime 3 scenario IR (JSON) over ESP-NOW in frames of
// up to 240 bytes: a 4-byte header { seq:u16 LE, total:u16 LE } followed by
// <=236 payload bytes. Same wire format as the master's scenario_mesh
// component and the puzzle nodes' shared espnow_slave.c receiver.
//
// PLIP registers no other esp_now recv callback, so this module owns the
// single callback — no demultiplexing against legacy MSG_* traffic needed
// (unlike the puzzle nodes). Frames are copied onto a FreeRTOS queue from
// the Wi-Fi task and reassembled per source MAC in a dedicated worker task;
// a completed scenario is persisted to LittleFS at /scenario.json
// (temp-then-rename, so a torn write never replaces a good scenario).
//
// Wi-Fi: network_task owns the connection. Call scenario_now_init() once
// the station is up (esp_now_init() fails before Wi-Fi starts); it is
// idempotent so the reconnect path may call it again.
#pragma once
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Start the receiver: ESP-NOW init, recv callback, worker task, LittleFS
// mount. Idempotent. Returns true when the receiver is (already) running.
bool scenario_now_init();
// Optional consumer hook, invoked from the worker task after the scenario
// has been persisted to LittleFS. `json` points at the reassembled buffer
// (valid only for the duration of the call). PLIP has no Runtime 3 engine
// yet: with no callback registered the scenario is stored and logged, ready
// for a future engine to load at boot.
typedef void (*scenario_now_callback_t)(const uint8_t src_mac[6],
const char *json, size_t len);
void scenario_now_register_callback(scenario_now_callback_t cb);
// Path of the persisted scenario on LittleFS.
const char *scenario_now_storage_path();
#ifdef __cplusplus
}
#endif
+21 -12
View File
@@ -6,10 +6,12 @@ Zacus est en refonte vers un produit hybride unique:
- 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/`
- Source narrative: `game/scenarios/zacus_v3_complete.yaml`
- Studio auteur: `frontend-v3/apps/atelier` (monorepo pnpm + turbo ; `frontend-scratch-v2/` est abandonné depuis le hard cutover du 2026-05)
- Hub game-master: `apps/zacus-hub` (app SwiftUI native)
- Gateway d'orchestration: `tools/zacus-gateway` (FastAPI, port 8400) — compile le YAML → Runtime 3 IR et pousse aux cartes
- Runtime portable: `tools/scenario/compile_runtime3.py` + `tools/scenario/simulate_runtime3.py`
- Cible hardware principale: `hardware/firmware` avec `freenove_esp32s3`
- Cible hardware principale: `ESP32_ZACUS/idf_zacus` (ESP-IDF, Freenove ESP32-S3) — submodule sur Gitea ; `hardware/firmware` (ancien build Arduino) est legacy
- Plans et memoire: `memory/`, `plans/`, `todos/`
- Architecture et cartes Mermaid: `docs/architecture/`
@@ -23,17 +25,20 @@ bash tools/test/run_content_checks.sh
### 2. Compiler et simuler Runtime 3
```bash
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
python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v3_complete.yaml
python3 tools/scenario/simulate_runtime3.py game/scenarios/zacus_v3_complete.yaml
python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v3_complete.yaml
```
### 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
cd frontend-v3
pnpm install
pnpm test
# Bases de liaison : gateway (:8400), firmware (:80), hints (:8311), voice-bridge (:8200)
VITE_GATEWAY_BASE_URL=http://localhost:8400 \
VITE_ESP32_BASE_URL=http://zacus-master.local \
pnpm dev
```
### 4. Utiliser le shell canonique
@@ -52,8 +57,12 @@ make runtime3-test
- `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.
- `frontend-v3/`: studio auteur (apps/atelier + apps/dashboard), monorepo pnpm + turbo. **Webapp courante.**
- `apps/zacus-hub/`: hub game-master (app SwiftUI native).
- `tools/zacus-gateway/`: gateway FastAPI (:8400) — orchestration studio/flash, compile YAML → Runtime 3 IR.
- `ESP32_ZACUS/`: firmware ESP-IDF (submodule Gitea), cible principale `idf_zacus`.
- `frontend-scratch-v2/`: studio auteur React + Blockly (legacy, abandonné).
- `hardware/firmware/`: ancien firmware Arduino freenove (legacy).
- `tools/`: validateurs, compilateur/simulateur Runtime 3, shells d'automatisation.
- `docs/`: quickstart, architecture, benchmark OSS et runbooks.
- `memory/`, `plans/`, `todos/`: pilotage de la refonte.
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ struct GameMasterView: View {
VStack(alignment: .leading, spacing: 24) {
header
backendsSection
sessionsSection
sceneSection
actionsSection
if !actionLog.isEmpty { logSection }
@@ -61,6 +62,39 @@ struct GameMasterView: View {
}
}
private var sessionsSection: some View {
GroupBox("Groupes en cours") {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("\(session.state.sessions_active) groupe(s)", systemImage: "person.3.fill")
.font(.caption.bold())
Spacer()
Label("\(session.state.hints_total) indice(s)", systemImage: "lightbulb")
.font(.caption).foregroundStyle(.secondary)
}
if session.state.sessions.isEmpty {
Text(session.state.state_detail ?? "Aucune session active.")
.font(.caption).foregroundStyle(.secondary)
} else {
ForEach(session.state.sessions) { snap in
HStack(spacing: 10) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(snap.total_penalty > 0 ? .orange : .green)
Text(snap.session_id).font(.body.monospaced())
if let puzzle = snap.active_puzzle {
Text(puzzle).font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Text("\(snap.total_hints) ind · \(snap.total_penalty) pén")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
}
}
}
private var sceneSection: some View {
GroupBox("Partie") {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
@@ -119,11 +119,24 @@ struct HubConfig: Codable, Equatable {
}
}
struct SessionSnapshot: Codable, Equatable, Identifiable, Hashable {
var session_id: String
var total_hints: Int = 0
var total_penalty: Int = 0
var active_puzzle: String?
var last_activity_ms: Int = 0
var id: String { session_id }
}
struct GameState: Codable, Equatable {
var scene_id: String?
var scene_index: Int = 0
var last_hint: String?
var voice_session: String?
var backends: [BackendHealth] = []
var sessions: [SessionSnapshot] = []
var hints_total: Int = 0
var sessions_active: Int = 0
var state_detail: String?
}
+71
View File
@@ -0,0 +1,71 @@
# Contrat CMD ESP-NOW — cartes annexes Zacus (roadmap)
**Statut** : design / roadmap (D5 du redesign DSL). Aucun firmware annexe n'implémente encore ce contrat — ce document le *spécifie* pour que les cartes M5 / BOX-3 / PLIP / p7_coffre le décodent plus tard.
## 1. État actuel (constaté)
Le master Freenove relaie vers ses pairs ESP-NOW de deux façons :
- **IR de scénario** : `POST /game/scenario/relay {peers,ir}``scenario_mesh_send` (chunké, ≤64 KiB, ack 300 ms). Sert à *pousser un scénario* à une carte.
- **Canal CMD texte** : `scenario_mesh_send_text`**texte libre UTF-8 ≤200 octets**, déclenché par `POST /game/espnow/cmd {peer, command}`. Aujourd'hui le master émet la trame (`{ok:true, ESP_OK}`) mais **aucun executor annexe ne consomme les CMD entrants** (elles sont seulement loguées).
Le routage gateway D2 (`/v1/media/play`, `/v1/step/{board}`) utilise déjà ce canal en best-effort avec des commandes texte ad-hoc : `play:<path>`, `step:<id>`. **D5 formalise ce texte ad-hoc en schéma structuré.**
## 2. Schéma CMD structuré
Trame = **JSON compact, UTF-8, ≤ 200 octets** (limite `scenario_mesh_send_text`). Forme :
```json
{"op":"<verbe>","a":{<args>}}
```
- `op` : verbe court (≤12 car.).
- `a` : objet d'arguments (clés courtes pour tenir dans 200 o). Absent si pas d'argument.
- Pas d'espaces superflus (séparateurs `,`/`:` compacts). Encodage côté gateway ; décodage côté carte annexe.
### Verbes (v1)
| `op` | Args (`a`) | Sémantique | Carte cible typique |
|---|---|---|---|
| `play` | `{"p":"<path>","l":<0\|1>}` | Jouer un média par chemin SD/LittleFS (`l`=loop) | toutes (audio) |
| `stop` | — | Stopper l'audio | toutes |
| `vol` | `{"v":<0-100>}` | Volume | toutes |
| `step` | `{"s":"<step_id>"}` | Armer une étape | toutes |
| `screen` | `{"t":"<title>","u":"<subtitle>","y":"<symbol>","e":"<effect>"}` | Afficher une scène LCD | M5/BOX-3 (écran) |
| `led` | `{"p":"<pattern>"}` | Motif LED | M5/BOX-3 |
| `ring` | `{"d":<sec>}` | Faire sonner (PLIP) | PLIP |
| `evt` | `{"n":"<name>"}` | Remonter un événement vers le master (sens carte→master) | toutes |
### Exemples (≤200 o)
```json
{"op":"play","a":{"p":"zacus/box3/intro.wav","l":0}}
{"op":"step","a":{"s":"STEP_LEFOU"}}
{"op":"screen","a":{"t":"LABORATOIRE","y":"LA","e":"pulse"}}
{"op":"evt","a":{"n":"solved:1"}}
```
## 3. Sens master → carte (CMD) et carte → master (EVT)
- **CMD** (master→pair) : émise par le gateway via `POST /game/espnow/cmd {peer, command:<json>}`. Le master relaie la trame ; la carte annexe décode `{op,a}` et exécute.
- **EVT** (pair→master) : la carte annexe émet `{"op":"evt","a":{"n":"<name>"}}` ; le master la remonte au gateway (endpoint à définir, p.ex. réutiliser le log CMD/EVT entrant). Les `evt` (`solved:<id>`, `button:<x>`, `done`) alimentent les **transitions réelles** (D4) côté gateway : `evt n="solved:1"` ↔ transition `onPuzzleSolved(1)` (`event_type:unlock`, `event_name:puzzle:1`).
## 4. Implémentation gateway (présent / futur)
- **Présent (D2)** : `/v1/media/play` et `/v1/step/{board}` envoient déjà du texte (`play:<path>`, `step:<id>`) via `/game/espnow/cmd`. À migrer vers le JSON structuré ci-dessus (changement local, sans toucher le firmware master).
- **Futur** : un endpoint d'**EVT entrant** (le master expose les CMD/EVT reçues) pour fermer la boucle carte→gateway→transition.
## 5. Implémentation firmware annexe (futur, hors de ce repo-ci)
Chaque carte annexe (M5 / BOX-3 / PLIP / p7_coffre) doit :
1. recevoir la trame ESP-NOW texte (callback existant côté master à relayer/exposer) ;
2. parser le JSON `{op,a}` (parser compact, tolérant : trame inconnue → ignorée + log) ;
3. dispatcher sur `op` vers ses capacités locales (audio I2S, écran, LED, sonnerie…) ;
4. émettre des `evt` pour les événements joueur (énigme résolue, bouton…).
Tant que ce firmware n'existe pas, les blocs de la **voie 🔬 hardware futur** (M5/BOX-3/PLIP, etc.) restent validés mais non exécutés — le compilateur émet le warning « hardware non câblé » (D1), et le routage relais du gateway répond `ok:false` + warning D5 (cf. `/v1/media/play`, `/v1/step` sur une carte annexe).
## 6. Contraintes & notes
- **Budget 200 o** : privilégier des clés courtes (`p`,`s`,`t`,`v`…). Un `screen` avec title+subtitle longs peut dépasser → tronquer côté gateway (limites firmware : title≤47, subtitle≤63, symbol≤15) avant émission.
- **Idempotence / ordre** : ESP-NOW n'est pas fiable ordonné ; les `op` doivent être idempotents quand c'est possible (`step`, `screen`, `vol`). Pour les séquences critiques, préférer le push d'IR complet (`/game/scenario/relay`).
- **Versionnement** : si le schéma évolue, ajouter un champ `v` au niveau racine (`{"v":2,"op":...}`) ; absence = v1.
@@ -0,0 +1,816 @@
# Énigmes locales Freenove (P1 son, P3 QR) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Faire de la Freenove ESP32-S3 Media Kit v1.2 (N16R8) un master portable tout-en-un qui scanne les QR (P3) via sa caméra et valide une mélodie (P1) via son micro, en réinjectant les résultats dans le master comme une énigme ESP-NOW.
**Architecture:** Nouveau composant `idf_zacus/components/local_puzzles/` (mic_broker, qr_puzzle, sound_puzzle, local_puzzle_report). Les validateurs de séquence sont de la logique C pure testée sur hôte (Unity). L'intégration matérielle (esp32-camera + quirc, refacto micro du voice_pipeline) vient ensuite. Une fondation d'agrégation « puzzle résolu » est ajoutée au master car elle n'existe pas encore.
**Tech Stack:** ESP-IDF 5.4, `espressif/esp32-camera`, `quirc` (décodeur QR C), `espressif/esp-sr` (existant), Unity (tests hôte IDF).
**Spec:** `docs/superpowers/specs/2026-06-10-freenove-local-puzzles-design.md`
---
## File Structure
| Fichier | Responsabilité |
|---|---|
| `idf_zacus/components/local_puzzles/include/local_puzzles.h` | API publique (init, report, arm/disarm par énigme) |
| `idf_zacus/components/local_puzzles/seq_validator.c/.h` | Validateur de séquence QR (ordre) — **logique pure** |
| `idf_zacus/components/local_puzzles/melody_validator.c/.h` | Validateur de mélodie (notes + tolérance) — **logique pure** |
| `idf_zacus/components/local_puzzles/mic_broker.c/.h` | Possède l'I2S RX, aiguille les frames par mode |
| `idf_zacus/components/local_puzzles/qr_puzzle.c` | Caméra + quirc + seq_validator |
| `idf_zacus/components/local_puzzles/sound_puzzle.c` | Frames micro + détection notes + melody_validator |
| `idf_zacus/components/local_puzzles/local_puzzles.c` | Câblage, `local_puzzle_report`, arm/disarm |
| `idf_zacus/components/local_puzzles/CMakeLists.txt` | Déclaration composant + REQUIRES |
| `idf_zacus/components/local_puzzles/idf_component.yml` | Deps managées (esp32-camera, quirc) |
| `idf_zacus/components/local_puzzles/test/test_validators.c` | Tests Unity hôte des deux validateurs |
| `idf_zacus/components/puzzle_state/…` | **Fondation** : agrégation des codes d'énigme + `POST /game/puzzle_solved` |
| `idf_zacus/components/voice_pipeline/voice_pipeline.c` | **Modifié** : tire le micro du broker |
| `idf_zacus/main/main.c` | **Modifié** : init puzzle_state + local_puzzles |
---
## Task 0: Réconcilier le pin-map Media Kit v1.2 (BLOQUANT, pas de code)
**Pourquoi bloquant :** le `voice_pipeline` occupe déjà `GPIO14` (BCLK), `GPIO15` (WS), `GPIO22` (DIN micro) et `GPIO11/12/13` (haut-parleur MAX98357A). Le pin-map caméra Freenove générique réutilise `GPIO15` (XCLK), `GPIO13` (PCLK), `GPIO11/12` (data) → **collision directe**. Sans le vrai brochage du Media Kit v1.2, le code GPIO de la caméra serait faux.
- [ ] **Step 1: Extraire le brochage réel**
Récupérer, depuis le schéma officiel **Freenove ESP32-S3 Media Kit v1.2** (PDF fourni avec le kit / dépôt Freenove `Freenove_Media_Kit_for_ESP32_S3`), les GPIO de : caméra (PWDN, RESET, XCLK, SIOD, SIOC, VSYNC, HREF, PCLK, D0-D7), micro I2S (BCLK, WS, DIN), haut-parleur (BCLK, LRC, DIN).
- [ ] **Step 2: Vérifier la coexistence**
Construire un tableau GPIO unique. Confirmer **zéro collision** entre caméra, micro et haut-parleur. Si collision réelle sur le Media Kit (caméra et micro partagent un bus), documenter la conséquence : P3 (caméra) et P1/NPC (micro) deviennent **mutuellement exclusifs dans le temps** — acceptable car déjà séquentiels par phase de jeu, mais à acter.
- [ ] **Step 3: Geler les constantes**
Écrire le tableau final dans `idf_zacus/components/local_puzzles/include/board_pins_mediakit.h` (macros `CAM_PIN_*`, et confirmer que `voice_pipeline` garde ses pins micro actuels). Commit :
```bash
git add idf_zacus/components/local_puzzles/include/board_pins_mediakit.h
git commit -m "docs(local): freeze Media Kit v1.2 pin map"
```
> **Sortie de Task 0** : `board_pins_mediakit.h` avec les `CAM_PIN_*` confirmés. Les tâches caméra en dépendent.
---
## Task 1: Validateur de séquence QR (logique pure, testable hôte)
**Files:**
- Create: `idf_zacus/components/local_puzzles/seq_validator.h`
- Create: `idf_zacus/components/local_puzzles/seq_validator.c`
- Test: `idf_zacus/components/local_puzzles/test/test_validators.c`
- [ ] **Step 1: Écrire le test qui échoue**
```c
#include "unity.h"
#include "seq_validator.h"
TEST_CASE("seq accepts codes scanned in the expected order", "[seq]")
{
const char *expected[] = {"qr3", "qr1", "qr4"};
seq_validator_t v;
seq_validator_init(&v, expected, 3);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr3")); // 1/3
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr1")); // 2/3
TEST_ASSERT_TRUE (seq_validator_feed(&v, "qr4")); // complete
}
TEST_CASE("seq resets on a wrong scan", "[seq]")
{
const char *expected[] = {"a", "b"};
seq_validator_t v;
seq_validator_init(&v, expected, 2);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2
TEST_ASSERT_FALSE(seq_validator_feed(&v, "x")); // wrong -> reset
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2 again
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b")); // complete
}
TEST_CASE("seq ignores a duplicate of the last correct scan", "[seq]")
{
const char *expected[] = {"a", "b"};
seq_validator_t v;
seq_validator_init(&v, expected, 2);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a"));
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // duplicate, no progress, no reset
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b"));
}
```
- [ ] **Step 2: Lancer le test (échec attendu)**
Run: cible de test Unity du composant (`idf.py -C idf_zacus/components/local_puzzles/test build` via un projet de test, ou la cible `unity` du composant).
Expected: FAIL — `seq_validator.h` introuvable.
- [ ] **Step 3: Écrire le header**
```c
// seq_validator.h — ordered-scan validator (QR P3). Pure logic, no I/O.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#define SEQ_MAX_STEPS 16
#define SEQ_MAX_LABEL 32
typedef struct {
char expected[SEQ_MAX_STEPS][SEQ_MAX_LABEL];
size_t count;
size_t pos; // next index to match
} seq_validator_t;
// expected: array of `count` C-strings (<= SEQ_MAX_STEPS, each < SEQ_MAX_LABEL).
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count);
// Feed one scanned code. Returns true exactly when the full sequence is done.
// A wrong code resets progress; a duplicate of the last matched code is ignored.
bool seq_validator_feed(seq_validator_t *v, const char *code);
```
- [ ] **Step 4: Écrire l'implémentation minimale**
```c
// seq_validator.c
#include "seq_validator.h"
#include <string.h>
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count)
{
memset(v, 0, sizeof(*v));
if (count > SEQ_MAX_STEPS) count = SEQ_MAX_STEPS;
v->count = count;
for (size_t i = 0; i < count; i++) {
strncpy(v->expected[i], expected[i], SEQ_MAX_LABEL - 1);
}
}
bool seq_validator_feed(seq_validator_t *v, const char *code)
{
if (v->count == 0 || v->pos >= v->count) return false;
// duplicate of the last matched code: ignore (no progress, no reset)
if (v->pos > 0 && strncmp(code, v->expected[v->pos - 1], SEQ_MAX_LABEL) == 0)
return false;
if (strncmp(code, v->expected[v->pos], SEQ_MAX_LABEL) == 0) {
v->pos++;
return v->pos == v->count;
}
v->pos = 0; // wrong scan -> reset
// allow the wrong scan to itself start a new run at index 0
if (strncmp(code, v->expected[0], SEQ_MAX_LABEL) == 0) v->pos = 1;
return false;
}
```
- [ ] **Step 5: Lancer le test (succès attendu)** — 3 cas `[seq]` verts.
- [ ] **Step 6: Commit**
```bash
git add idf_zacus/components/local_puzzles/seq_validator.h \
idf_zacus/components/local_puzzles/seq_validator.c \
idf_zacus/components/local_puzzles/test/test_validators.c
git commit -m "feat(local): QR sequence validator + tests"
```
---
## Task 2: Validateur de mélodie (logique pure, testable hôte)
**Files:**
- Create: `idf_zacus/components/local_puzzles/melody_validator.h`
- Create: `idf_zacus/components/local_puzzles/melody_validator.c`
- Modify: `idf_zacus/components/local_puzzles/test/test_validators.c` (ajouter des cas)
- [ ] **Step 1: Écrire le test qui échoue**
```c
#include "melody_validator.h"
// Notes as MIDI numbers; tolerance in semitones. The player reproduces a melody;
// the detector hands us one note at a time, we accept the sequence within tolerance.
TEST_CASE("melody accepts the exact expected note sequence", "[melody]")
{
const int expected[] = {60, 62, 64, 65}; // do re mi fa
melody_validator_t m;
melody_validator_init(&m, expected, 4, /*tolerance=*/1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 62));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 64));
TEST_ASSERT_TRUE (melody_validator_feed(&m, 65));
}
TEST_CASE("melody accepts notes within tolerance", "[melody]")
{
const int expected[] = {60, 62};
melody_validator_t m;
melody_validator_init(&m, expected, 2, 1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 61)); // 60 +/-1 ok
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
}
TEST_CASE("melody resets on an out-of-tolerance note", "[melody]")
{
const int expected[] = {60, 62};
melody_validator_t m;
melody_validator_init(&m, expected, 2, 1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 70)); // way off -> reset
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
}
```
- [ ] **Step 2: Lancer le test (échec attendu)**`melody_validator.h` introuvable.
- [ ] **Step 3: Écrire le header**
```c
// melody_validator.h — note-sequence validator (P1). Pure logic, no I/O.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#define MELODY_MAX_NOTES 32
typedef struct {
int expected[MELODY_MAX_NOTES]; // MIDI note numbers
size_t count;
int tolerance; // accepted deviation in semitones
size_t pos;
} melody_validator_t;
void melody_validator_init(melody_validator_t *m, const int *expected,
size_t count, int tolerance);
// Feed one detected note (MIDI). True when the full melody is matched.
bool melody_validator_feed(melody_validator_t *m, int note);
```
- [ ] **Step 4: Écrire l'implémentation**
```c
// melody_validator.c
#include "melody_validator.h"
#include <string.h>
#include <stdlib.h>
void melody_validator_init(melody_validator_t *m, const int *expected,
size_t count, int tolerance)
{
memset(m, 0, sizeof(*m));
if (count > MELODY_MAX_NOTES) count = MELODY_MAX_NOTES;
m->count = count;
m->tolerance = tolerance;
for (size_t i = 0; i < count; i++) m->expected[i] = expected[i];
}
bool melody_validator_feed(melody_validator_t *m, int note)
{
if (m->count == 0 || m->pos >= m->count) return false;
if (abs(note - m->expected[m->pos]) <= m->tolerance) {
m->pos++;
return m->pos == m->count;
}
// out of tolerance: reset, but let this note seed index 0 if it fits
m->pos = 0;
if (abs(note - m->expected[0]) <= m->tolerance) m->pos = 1;
return false;
}
```
- [ ] **Step 5: Lancer le test (succès attendu)** — cas `[melody]` verts.
- [ ] **Step 6: Commit**
```bash
git add idf_zacus/components/local_puzzles/melody_validator.h \
idf_zacus/components/local_puzzles/melody_validator.c \
idf_zacus/components/local_puzzles/test/test_validators.c
git commit -m "feat(local): melody validator + tests"
```
---
## Task 3: Fondation — agrégation « puzzle résolu » dans le master
**Pourquoi :** `idf_zacus` n'a aujourd'hui **aucun** chemin de réception d'énigme résolue (ni ESP-NOW, ni HTTP). `local_puzzle_report` n'a donc rien à mirrorer — on crée la fondation, que les énigmes locales **et** futures distantes utiliseront.
**Files:**
- Create: `idf_zacus/components/puzzle_state/include/puzzle_state.h`
- Create: `idf_zacus/components/puzzle_state/puzzle_state.c`
- Create: `idf_zacus/components/puzzle_state/CMakeLists.txt`
- Test: `idf_zacus/components/puzzle_state/test/test_puzzle_state.c`
- [ ] **Step 1: Écrire le test qui échoue**
```c
#include "unity.h"
#include "puzzle_state.h"
TEST_CASE("assembles the code from reported fragments", "[pstate]")
{
puzzle_state_t s;
puzzle_state_init(&s);
puzzle_state_report(&s, /*id=*/1, (const uint8_t[]){1,2,0,0}, 2);
puzzle_state_report(&s, /*id=*/3, (const uint8_t[]){3,4,0,0}, 2);
char code[16];
TEST_ASSERT_EQUAL_INT(4, puzzle_state_code(&s, code, sizeof(code)));
TEST_ASSERT_EQUAL_STRING("1234", code);
}
TEST_CASE("a puzzle reported twice does not duplicate its digits", "[pstate]")
{
puzzle_state_t s;
puzzle_state_init(&s);
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
char code[16];
TEST_ASSERT_EQUAL_INT(2, puzzle_state_code(&s, code, sizeof(code)));
TEST_ASSERT_EQUAL_STRING("12", code);
}
```
- [ ] **Step 2: Lancer le test (échec attendu)** — header introuvable.
- [ ] **Step 3: Écrire le header**
```c
// puzzle_state.h — master-side aggregation of solved puzzles + assembled code.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define PUZZLE_MAX_ID 8
#define PUZZLE_MAX_FRAG 4
typedef struct {
bool solved[PUZZLE_MAX_ID + 1];
uint8_t frag[PUZZLE_MAX_ID + 1][PUZZLE_MAX_FRAG];
uint8_t frag_len[PUZZLE_MAX_ID + 1];
} puzzle_state_t;
void puzzle_state_init(puzzle_state_t *s);
// Record a solved puzzle. Idempotent per id. `len` <= PUZZLE_MAX_FRAG.
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
const uint8_t *fragment, uint8_t len);
// Write the assembled code (digits of all solved puzzles, by ascending id)
// as a NUL-terminated string. Returns the number of digits written.
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap);
```
- [ ] **Step 4: Écrire l'implémentation**
```c
// puzzle_state.c
#include "puzzle_state.h"
#include <string.h>
void puzzle_state_init(puzzle_state_t *s) { memset(s, 0, sizeof(*s)); }
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
const uint8_t *fragment, uint8_t len)
{
if (id == 0 || id > PUZZLE_MAX_ID) return;
if (len > PUZZLE_MAX_FRAG) len = PUZZLE_MAX_FRAG;
if (s->solved[id]) return; // idempotent
s->solved[id] = true;
s->frag_len[id] = len;
memcpy(s->frag[id], fragment, len);
}
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap)
{
int n = 0;
for (uint8_t id = 1; id <= PUZZLE_MAX_ID; id++) {
if (!s->solved[id]) continue;
for (uint8_t k = 0; k < s->frag_len[id] && (size_t)(n + 1) < cap; k++)
out[n++] = (char)('0' + (s->frag[id][k] % 10));
}
if ((size_t)n < cap) out[n] = '\0';
return n;
}
```
- [ ] **Step 5: Écrire le CMakeLists du composant**
```cmake
idf_component_register(
SRCS "puzzle_state.c"
INCLUDE_DIRS "include"
)
```
- [ ] **Step 6: Lancer le test (succès attendu)** — cas `[pstate]` verts.
- [ ] **Step 7: Commit**
```bash
git add idf_zacus/components/puzzle_state/
git commit -m "feat(master): puzzle-solved aggregation + code assembly"
```
---
## Task 4: `mic_broker` + refacto `voice_pipeline`
**Files:**
- Create: `idf_zacus/components/local_puzzles/mic_broker.h`
- Create: `idf_zacus/components/local_puzzles/mic_broker.c`
- Modify: `idf_zacus/components/voice_pipeline/voice_pipeline.c` (i2s_setup + capture_task)
- [ ] **Step 1: Écrire le header du broker**
```c
// mic_broker.h — single owner of the I2S RX mic; routes frames to one
// active consumer chosen by mode. No frame is delivered in MIC_IDLE.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
typedef enum { MIC_IDLE, MIC_NPC_LISTEN, MIC_P1_SOUND } mic_mode_t;
// cb receives PCM16 mono frames (count = samples) when its mode is active.
typedef void (*mic_frame_cb_t)(const int16_t *pcm, size_t samples, void *ctx);
esp_err_t mic_broker_init(int bclk_pin, int ws_pin, int din_pin, int sample_rate_hz);
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx);
void mic_broker_set_mode(mic_mode_t mode);
mic_mode_t mic_broker_mode(void);
```
- [ ] **Step 2: Écrire l'implémentation du broker**
```c
// mic_broker.c
#include "mic_broker.h"
#include "driver/i2s_std.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define MB_FRAME 320 // 20 ms @ 16 kHz
static i2s_chan_handle_t s_rx;
static volatile mic_mode_t s_mode = MIC_IDLE;
static struct { mic_frame_cb_t cb; void *ctx; } s_consumers[3];
static void capture_task(void *arg) {
int16_t buf[MB_FRAME];
size_t got;
for (;;) {
if (i2s_channel_read(s_rx, buf, sizeof(buf), &got, portMAX_DELAY) != ESP_OK) continue;
mic_mode_t m = s_mode;
if (m != MIC_IDLE && s_consumers[m].cb)
s_consumers[m].cb(buf, got / sizeof(int16_t), s_consumers[m].ctx);
}
}
esp_err_t mic_broker_init(int bclk, int ws, int din, int sr) {
i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
esp_err_t e = i2s_new_channel(&cc, NULL, &s_rx);
if (e != ESP_OK) return e;
i2s_std_config_t std = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sr),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = { .bclk = bclk, .ws = ws, .din = din, .mclk = I2S_GPIO_UNUSED,
.dout = I2S_GPIO_UNUSED, .invert_flags = {0} },
};
e = i2s_channel_init_std_mode(s_rx, &std);
if (e != ESP_OK) return e;
e = i2s_channel_enable(s_rx);
if (e != ESP_OK) return e;
xTaskCreate(capture_task, "mic_broker", 4096, NULL, 5, NULL);
return ESP_OK;
}
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx) {
if (mode == MIC_IDLE) return;
s_consumers[mode].cb = cb; s_consumers[mode].ctx = ctx;
}
void mic_broker_set_mode(mic_mode_t mode) { s_mode = mode; }
mic_mode_t mic_broker_mode(void) { return s_mode; }
```
- [ ] **Step 3: Refacto `voice_pipeline` pour consommer le broker**
Dans `voice_pipeline.c`, remplacer l'init I2S RX locale (`i2s_setup`, `rx_chan`, lecture dans `capture_task`) par : `mic_broker_init(cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin, 16000)` au démarrage, puis `mic_broker_register(MIC_NPC_LISTEN, on_npc_frame, &s_pipe)``on_npc_frame` contient le corps actuel de la boucle de capture (feed AFE). Conserver l'I2S TX (haut-parleur) inchangé. Au passage en écoute NPC : `mic_broker_set_mode(MIC_NPC_LISTEN)`.
- [ ] **Step 4: Build + smoke régression**
Run: `idf.py -C idf_zacus build && idf.py -C idf_zacus -p /dev/cu.usbmodem5AB90753301 flash monitor`
Expected: NPC vocal toujours fonctionnel (wakeword + stream WS), aucun log d'erreur I2S.
- [ ] **Step 5: Commit**
```bash
git add idf_zacus/components/local_puzzles/mic_broker.h \
idf_zacus/components/local_puzzles/mic_broker.c \
idf_zacus/components/voice_pipeline/voice_pipeline.c
git commit -m "feat(local): mic broker; voice_pipeline pulls from it"
```
---
## Task 5: `qr_puzzle` (caméra OV2640 + quirc + seq_validator)
**Files:**
- Create: `idf_zacus/components/local_puzzles/qr_puzzle.c`
- Create: `idf_zacus/components/local_puzzles/include/qr_puzzle.h`
- Create/Modify: `idf_zacus/components/local_puzzles/idf_component.yml`
- [ ] **Step 1: Déclarer les deps managées**
```yaml
dependencies:
espressif/esp32-camera:
version: ">=2.0.0"
espressif/quirc: # si absent du registre, vendorer quirc (note ci-dessous)
version: "*"
```
> Si `espressif/quirc` n'existe pas au registre : vendorer `quirc` (lib C, ~5 fichiers) sous `idf_zacus/components/quirc/` avec un `CMakeLists.txt` minimal `idf_component_register(SRCS quirc.c decode.c identify.c version_db.c INCLUDE_DIRS .)`.
- [ ] **Step 2: Écrire `qr_puzzle.h`**
```c
// qr_puzzle.h
#pragma once
#include <stddef.h>
#include "esp_err.h"
typedef void (*qr_solved_cb_t)(void);
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb);
void qr_puzzle_stop(void);
```
- [ ] **Step 3: Écrire `qr_puzzle.c`**
```c
// qr_puzzle.c — camera capture + quirc decode; validates QR order.
#include "qr_puzzle.h"
#include "seq_validator.h"
#include "board_pins_mediakit.h"
#include "esp_camera.h"
#include "quirc.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#define TAG "qr_puzzle"
static seq_validator_t s_seq;
static qr_solved_cb_t s_cb;
static TaskHandle_t s_task;
static volatile bool s_run;
static camera_config_t cam_cfg(void) {
camera_config_t c = {
.pin_pwdn = CAM_PIN_PWDN, .pin_reset = CAM_PIN_RESET, .pin_xclk = CAM_PIN_XCLK,
.pin_sccb_sda = CAM_PIN_SIOD, .pin_sccb_scl = CAM_PIN_SIOC,
.pin_d7 = CAM_PIN_D7, .pin_d6 = CAM_PIN_D6, .pin_d5 = CAM_PIN_D5, .pin_d4 = CAM_PIN_D4,
.pin_d3 = CAM_PIN_D3, .pin_d2 = CAM_PIN_D2, .pin_d1 = CAM_PIN_D1, .pin_d0 = CAM_PIN_D0,
.pin_vsync = CAM_PIN_VSYNC, .pin_href = CAM_PIN_HREF, .pin_pclk = CAM_PIN_PCLK,
.xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_GRAYSCALE, .frame_size = FRAMESIZE_QVGA,
.fb_count = 1, .fb_location = CAMERA_FB_IN_PSRAM, .grab_mode = CAMERA_GRAB_LATEST,
};
return c;
}
static void scan_task(void *arg) {
struct quirc *q = quirc_new();
quirc_resize(q, 320, 240);
while (s_run) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) { vTaskDelay(pdMS_TO_TICKS(50)); continue; }
uint8_t *img = quirc_begin(q, NULL, NULL);
memcpy(img, fb->buf, 320 * 240);
quirc_end(q);
int n = quirc_count(q);
for (int i = 0; i < n; i++) {
struct quirc_code code; struct quirc_data data;
quirc_extract(q, i, &code);
if (quirc_decode(&code, &data) == 0) {
if (seq_validator_feed(&s_seq, (const char *)data.payload)) {
if (s_cb) s_cb();
s_run = false;
}
}
}
esp_camera_fb_return(fb);
vTaskDelay(pdMS_TO_TICKS(30));
}
quirc_destroy(q);
esp_camera_deinit(); // free PSRAM + power down (battery)
s_task = NULL;
vTaskDelete(NULL);
}
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb) {
if (s_task) return ESP_ERR_INVALID_STATE;
camera_config_t cfg = cam_cfg();
esp_err_t e = esp_camera_init(&cfg);
if (e != ESP_OK) { ESP_LOGE(TAG, "camera init: 0x%x", e); return e; }
seq_validator_init(&s_seq, expected, count);
s_cb = cb; s_run = true;
xTaskCreate(scan_task, "qr_scan", 8192, NULL, 5, &s_task);
return ESP_OK;
}
void qr_puzzle_stop(void) { s_run = false; }
```
- [ ] **Step 4: Smoke hardware**
Armer P3 (Task 7) avec un set de QR connus, présenter les QR dans l'ordre devant la caméra.
Expected: log `qr_puzzle` reconnaît chaque code ; au dernier, le callback se déclenche.
- [ ] **Step 5: Commit**
```bash
git add idf_zacus/components/local_puzzles/qr_puzzle.c \
idf_zacus/components/local_puzzles/include/qr_puzzle.h \
idf_zacus/components/local_puzzles/idf_component.yml
git commit -m "feat(local): QR puzzle via camera + quirc"
```
---
## Task 6: `sound_puzzle` (frames micro + détection notes + melody_validator)
**Files:**
- Create: `idf_zacus/components/local_puzzles/sound_puzzle.c`
- Create: `idf_zacus/components/local_puzzles/include/sound_puzzle.h`
- [ ] **Step 1: Écrire `sound_puzzle.h`**
```c
// sound_puzzle.h
#pragma once
#include <stddef.h>
typedef void (*sound_solved_cb_t)(void);
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb);
void sound_puzzle_stop(void);
```
- [ ] **Step 2: Écrire `sound_puzzle.c`**
```c
// sound_puzzle.c — consumes MIC_P1_SOUND frames, estimates note, validates melody.
#include "sound_puzzle.h"
#include "melody_validator.h"
#include "mic_broker.h"
#include <math.h>
static melody_validator_t s_mel;
static sound_solved_cb_t s_cb;
static int s_last_note = -1000;
// Crude pitch: zero-crossing rate -> fundamental -> MIDI. Replaced by an
// autocorrelation estimator if accuracy is insufficient (documented limit).
static int frame_to_midi(const int16_t *pcm, size_t n, int sr) {
size_t zc = 0;
for (size_t i = 1; i < n; i++) if ((pcm[i-1] < 0) != (pcm[i] < 0)) zc++;
if (zc < 2) return -1000; // silence
double freq = (double)zc * sr / (2.0 * n);
if (freq < 80 || freq > 2000) return -1000;
return (int)lround(69 + 12 * log2(freq / 440.0));
}
static void on_frame(const int16_t *pcm, size_t n, void *ctx) {
int note = frame_to_midi(pcm, n, 16000);
if (note <= -1000) { s_last_note = -1000; return; }
if (note == s_last_note) return; // debounce sustained note
s_last_note = note;
if (melody_validator_feed(&s_mel, note)) { if (s_cb) s_cb(); }
}
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb) {
melody_validator_init(&s_mel, expected, count, tol);
s_cb = cb; s_last_note = -1000;
mic_broker_register(MIC_P1_SOUND, on_frame, NULL);
mic_broker_set_mode(MIC_P1_SOUND);
}
void sound_puzzle_stop(void) { mic_broker_set_mode(MIC_IDLE); }
```
- [ ] **Step 3: Smoke hardware**
Armer P1, jouer la mélodie attendue (instrument/voix) devant le micro.
Expected: les notes détectées progressent ; à la dernière, le callback se déclenche. Documenter dans le commit la limite connue de l'estimateur (zero-crossing) si la robustesse est insuffisante (piste : autocorrélation / `esp-dsp` FFT).
- [ ] **Step 4: Commit**
```bash
git add idf_zacus/components/local_puzzles/sound_puzzle.c \
idf_zacus/components/local_puzzles/include/sound_puzzle.h
git commit -m "feat(local): sound puzzle via mic broker"
```
---
## Task 7: Câblage `local_puzzles` + flag config local|remote + main.c
**Files:**
- Create: `idf_zacus/components/local_puzzles/local_puzzles.c`
- Create: `idf_zacus/components/local_puzzles/include/local_puzzles.h`
- Create: `idf_zacus/components/local_puzzles/CMakeLists.txt`
- Modify: `idf_zacus/main/main.c`
- [ ] **Step 1: Écrire `local_puzzles.h`**
```c
// local_puzzles.h
#pragma once
#include <stddef.h>
#include "esp_err.h"
#include "puzzle_state.h"
void local_puzzles_init(puzzle_state_t *state);
esp_err_t local_puzzles_arm_qr(const char *const *expected, size_t count);
void local_puzzles_arm_sound(const int *expected, size_t count, int tol);
```
- [ ] **Step 2: Écrire `local_puzzles.c`**
```c
// local_puzzles.c — wires QR/sound to puzzle_state and arms by puzzle id.
#include "local_puzzles.h"
#include "qr_puzzle.h"
#include "sound_puzzle.h"
static puzzle_state_t *s_state;
// Code-fragment slots per scenario (placeholder values; the scenario engine
// will supply real fragments in a later iteration).
static void on_qr_solved(void) { puzzle_state_report(s_state, 3, (const uint8_t[]){5,0,0,0}, 1); }
static void on_sound_solved(void) { puzzle_state_report(s_state, 1, (const uint8_t[]){1,2,0,0}, 2); }
void local_puzzles_init(puzzle_state_t *state) { s_state = state; }
esp_err_t local_puzzles_arm_qr(const char *const *expected, size_t count) {
return qr_puzzle_start(expected, count, on_qr_solved);
}
void local_puzzles_arm_sound(const int *expected, size_t count, int tol) {
sound_puzzle_start(expected, count, tol, on_sound_solved);
}
```
- [ ] **Step 3: Écrire le `CMakeLists.txt` du composant**
```cmake
idf_component_register(
SRCS "local_puzzles.c" "mic_broker.c" "qr_puzzle.c" "sound_puzzle.c"
"seq_validator.c" "melody_validator.c"
INCLUDE_DIRS "include" "."
PRIV_REQUIRES driver esp_timer puzzle_state
REQUIRES esp_camera
)
```
- [ ] **Step 4: Initialiser dans `main.c`**
Après l'init Wi-Fi / `game_endpoint`, ajouter : `static puzzle_state_t s_pstate;` global, `puzzle_state_init(&s_pstate);`, `local_puzzles_init(&s_pstate);`, et `mic_broker_init(14, 15, 22, 16000);` (pins micro du `voice_pipeline` — à aligner avec Task 0). L'armement de P1/P3 est déclenché par l'étape de scénario active (gating via le moteur de scénario existant) ; en V1, appel direct `local_puzzles_arm_qr(...)` / `local_puzzles_arm_sound(...)` lors de la phase correspondante avec les valeurs du scénario chargé.
- [ ] **Step 5: Build + flash + smoke complet**
Run: `idf.py -C idf_zacus -p /dev/cu.usbmodem5AB90753301 flash monitor`
Expected: build vert, P3 puis P1 résolus localement déclenchent `puzzle_state_report` et le code s'assemble (`puzzle_state_code`).
- [ ] **Step 6: Commit**
```bash
git add idf_zacus/components/local_puzzles/local_puzzles.c \
idf_zacus/components/local_puzzles/include/local_puzzles.h \
idf_zacus/components/local_puzzles/CMakeLists.txt \
idf_zacus/main/main.c
git commit -m "feat(local): wire local puzzles into the master"
```
---
## Self-review (couverture spec)
- mic_broker + contention → Task 4 ✓
- qr_puzzle (caméra+quirc+ordre) → Task 5 ✓
- sound_puzzle (mélodie) → Task 6 ✓
- local_puzzle_report → fondu dans `puzzle_state` (Task 3) + callbacks `local_puzzles` (Task 7) ✓
- flag local|remote → Task 7 Step 4 (gating scénario) ✓ ; config persistante `boards.yaml` = extension future, notée
- validateurs testables hôte → Tasks 1, 2 ✓
- pin-map / conflit GPIO → Task 0 (bloquant) ✓
- **Écart spec assumé** : le spec supposait un chemin `MSG_PUZZLE_SOLVED` existant ; il n'existe pas dans `idf_zacus` → fondation `puzzle_state` ajoutée (Task 3). Le repli ESP-NOW « si le nœud existe » reste théorique tant que le master ne reçoit pas d'ESP-NOW puzzle — à traiter dans un plan ultérieur si P5/P6/P7 doivent aussi remonter au master.
## Inconnues à lever en exécution
1. Pins réelles caméra/micro/HP du Media Kit v1.2 (Task 0, bloquant).
2. Présence de `espressif/quirc` au registre, sinon vendoring (Task 5 Step 1).
3. Robustesse de l'estimateur de hauteur zero-crossing vs autocorrélation/FFT `esp-dsp` (Task 6 Step 3).
4. Fragments de code réels par énigme (actuellement placeholders dans `local_puzzles.c`) — fournis par le moteur de scénario.
@@ -0,0 +1,584 @@
# Atelier Refonte « Workbench Console » Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refondre l'UI de `apps/atelier` : retirer la simulation 3D, appliquer un thème néo-rétro « Workbench » (palette firmware), et remplacer la colonne 3D par un Banc d'essai (aperçu écran device fidèle + vue flux du scénario, split vertical). Blockly est conservé.
**Architecture:** Trois phases séquentielles — A) soustraction 3D (build doit rester vert), B) thème Workbench (CSS + thème Blockly + re-skin), C) Banc d'essai (`WorkbenchPane` = `DeviceScreen` + `FlowGraph` pilotés par un `selectionStore`, lisant l'IR de `runtimeStore`).
**Tech Stack:** React 19, TypeScript (strict, `exactOptionalPropertyTypes`), Zustand, react-resizable-panels, Blockly, Vite. Verif par tâche : `pnpm exec tsc --noEmit` + `pnpm exec vite build` verts (pas de tests unitaires pour le rendu visuel ; les hooks e2e existants sont mis à jour en phase A).
**Réfs :** spec `docs/superpowers/specs/2026-06-13-atelier-refonte-workbench-design.md` ; types IR `frontend-v3/packages/scenario-engine/src/runtime-ir.ts` (`RuntimeIr.steps: RuntimeStep[]`, `RuntimeStep.id`, `RuntimeStep.scene?: StepScene{title,subtitle,symbol,effect}`, `RuntimeTransition.target_step_id`).
**Convention commit :** `git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit`. Toutes les commandes depuis `frontend-v3/apps/atelier` sauf indication.
---
## File Structure
| Fichier | Responsabilité | Phase |
|---|---|---|
| `src/components/StagePane.tsx` + `src/scene/*` + `src/puzzles/*` + `src/modes/*` + `src/stores/simStore.ts` | **supprimés** (3D) | A |
| `src/components/Layout.tsx` | layout : Blockly + `WorkbenchPane` + console ; toggle `⌘B` du banc | A, C |
| `src/components/ConsolePane.tsx` | console validation seule (retrait des onglets de modes) | A |
| `src/App.tsx` | hooks dev sans `sim` | A |
| `vite.config.ts` | retrait `manualChunks` three/r3f | A |
| `package.json` | retrait deps three/r3f | A |
| `src/theme/workbench.ts` | constantes palette/fonts (source unique) | B |
| `src/main.css` | shell + chrome Workbench | B |
| `src/components/BlocklyWorkspace.tsx` | thème Blockly Workbench | B |
| `src/components/MenuBar.tsx`, panneaux flottants | re-skin | B |
| `src/lib/screenSpec.ts` | déjà présent (P3a) — source des couleurs/limites écran | B (réf) |
| `src/components/DeviceScreenView.tsx` | rendu LCD fidèle réutilisable (extrait de `ScreenEditorPanel`) | C |
| `src/stores/selectionStore.ts` | `selectedStepId` | C |
| `src/lib/irSteps.ts` | parse `currentIr``{steps, edges}` | C |
| `src/components/FlowGraph.tsx` | graphe d'étapes cliquable | C |
| `src/components/DeviceScreen.tsx` | écran de l'étape sélectionnée | C |
| `src/components/WorkbenchPane.tsx` | split vertical Écran/Flux | C |
---
## PHASE A — Retrait de la 3D
### Task A1: Supprimer les fichiers 3D et le simStore
**Files:**
- Delete: `src/components/StagePane.tsx`, `src/scene/` (NpcBubble, PuzzleStation, PuzzleStations, Room, RoomScene, RtcPhone), `src/puzzles/` (P1Sound, P5Morse, P6Symbols, P7Coffre), `src/modes/` (DemoMode, SandboxMode, TestMode), `src/stores/simStore.ts`
- [ ] **Step 1: Supprimer les répertoires/fichiers 3D**
```bash
cd frontend-v3/apps/atelier
git rm -r src/scene src/puzzles src/modes src/components/StagePane.tsx src/stores/simStore.ts
```
- [ ] **Step 2: Lister les imports résiduels à corriger**
Run: `grep -rnE "simStore|StagePane|/scene/|/puzzles/|/modes/" src/ || echo "AUCUN"`
Expected: occurrences dans `Layout.tsx`, `App.tsx`, `ConsolePane.tsx`, `StagePane`-consumers — traitées aux tâches suivantes. (Ne pas committer encore : le build est cassé jusqu'à A4.)
### Task A2: Layout sans StagePane (placeholder banc en phase A)
**Files:**
- Modify: `src/components/Layout.tsx`
- [ ] **Step 1: Remplacer l'import + le panneau droit**
Dans `Layout.tsx`, retirer `import { StagePane } from './StagePane.js';` et remplacer le `<Panel ...><StagePane /></Panel>` droit par un placeholder neutre (le vrai `WorkbenchPane` arrive en phase C) :
```tsx
// en tête : remplacer l'import StagePane par :
// (phase C remplacera ce placeholder par WorkbenchPane)
// dans le PanelGroup horizontal, panneau droit :
<Panel
ref={stageRef}
defaultSize={45}
minSize={20}
collapsible
collapsedSize={0}
>
<div className="atelier-pane" style={{ display: 'grid', placeItems: 'center', color: '#9a9a9d' }}>
Banc d'essai (à venir)
</div>
</Panel>
```
Garder le toggle `⌘B` et `stageRef` inchangés (ils piloteront le banc).
- [ ] **Step 2: Vérifier qu'aucune réf StagePane ne subsiste dans Layout**
Run: `grep -n "StagePane" src/components/Layout.tsx || echo "OK"`
Expected: `OK`
### Task A3: ConsolePane sans onglets de modes
**Files:**
- Modify: `src/components/ConsolePane.tsx`
- [ ] **Step 1: Inspecter les dépendances sim de la console**
Run: `grep -nE "simStore|mode|Mode|SandboxMode|DemoMode|TestMode" src/components/ConsolePane.tsx`
Expected: identifie les imports/usages liés aux modes.
- [ ] **Step 2: Retirer l'état mode + l'onglet de modes, garder la validation**
Retirer de `ConsolePane.tsx` tout import de `simStore`/`modes/*` et le sélecteur d'onglets de modes. Conserver le rendu de la liste de validation (entries de `validationStore`). Le composant ne garde qu'un titre « Validation » + la liste des entrées. Remplacer toute lecture `simStore.getState().mode` par rien (supprimer la branche).
- [ ] **Step 3: Vérifier**
Run: `grep -nE "simStore|/modes/" src/components/ConsolePane.tsx || echo "OK"`
Expected: `OK`
### Task A4: App.tsx — hooks dev sans `sim`, retrait deps, build vert
**Files:**
- Modify: `src/App.tsx`, `vite.config.ts`, `package.json`
- [ ] **Step 1: Retirer `sim` des hooks dev de App.tsx**
Dans `src/App.tsx`, retirer `import { useSimStore } from './stores/simStore.js';` et la clé `sim: useSimStore,` de l'objet `window.__atelierStores`. Laisser `editor`, `runtime`, `validation`.
- [ ] **Step 2: Retirer le manualChunks three/r3f de vite.config.ts**
Run: `grep -nE "three|fiber|drei|r3f" vite.config.ts`
Puis supprimer les entrées `manualChunks` correspondantes (three / r3f) du `build.rollupOptions.output`. Garder le chunk blockly.
- [ ] **Step 3: Retirer les deps 3D de package.json**
```bash
cd frontend-v3/apps/atelier
pnpm remove three @types/three @react-three/fiber @react-three/drei
```
Expected: met à jour `package.json` + lockfile.
- [ ] **Step 4: Typecheck + build verts**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS, aucun import three/r3f restant. Si erreur « cannot find module ./stores/simStore » ou scene/puzzles/modes → corriger le consommateur résiduel signalé.
- [ ] **Step 5: Nettoyer les specs e2e pilotant la sim**
Run: `grep -rnlE "__atelierStores.*sim|simStore|SandboxMode|DemoMode|TestMode" ../../../e2e ../../e2e 2>/dev/null || echo "AUCUN e2e sim"`
Pour chaque spec trouvée : retirer/mettre à jour les assertions sur `sim`/modes (la sim n'existe plus). Si aucune, passer.
- [ ] **Step 6: Commit phase A**
```bash
cd frontend-v3/apps/atelier
git add -A && cd ../../.. && git add -A
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "refactor(atelier): remove 3D simulation (StagePane/scene/puzzles/modes/simStore, three deps)"
```
---
## PHASE B — Thème Workbench
### Task B1: Module de thème (source unique)
**Files:**
- Create: `src/theme/workbench.ts`
- [ ] **Step 1: Créer les constantes de thème**
```ts
// src/theme/workbench.ts
// Palette néo-rétro « Workbench » — alignée sur ESP32_ZACUS display_ui.cpp
// (COL_BG/COL_CODE/COL_VALUE/COL_LABEL) et lib/screenSpec.ts.
export const WB = {
bg: '#0a1722', // surface profonde (dérivée du bleu Workbench assombri)
panel: '#0f2233', // panneaux
panelBorder: '#1c3b52',
workbenchBlue: '#0055AA', // COL_BG firmware (accents/écran)
orange: '#FF8800', // COL_CODE — accent principal
value: '#FFFFFF', // COL_VALUE
label: '#AAAAAA', // COL_LABEL
led: { ok: '#34d399', warn: '#fbbf24', err: '#fca5a5', off: '#3b4a57' },
fontTitle: "'Orbitron','Arial Narrow',sans-serif",
fontMono: "'IBM Plex Mono',ui-monospace,monospace",
} as const;
```
- [ ] **Step 2: Typecheck**
Run: `pnpm exec tsc --noEmit`
Expected: PASS.
### Task B2: Polices locales + shell CSS Workbench
**Files:**
- Modify: `src/main.css`
- [ ] **Step 1: Charger Orbitron + IBM Plex Mono**
Les polices sont déjà bundlées par le firmware mais pas le front. Utiliser `@fontsource` (offline, pas de CDN) :
```bash
cd frontend-v3/apps/atelier
pnpm add @fontsource/orbitron @fontsource/ibm-plex-mono
```
Puis en tête de `src/main.css` :
```css
@import '@fontsource/orbitron/400.css';
@import '@fontsource/orbitron/700.css';
@import '@fontsource/ibm-plex-mono/400.css';
```
- [ ] **Step 2: Appliquer le thème au shell**
Dans `src/main.css`, mettre à jour `.atelier-shell`, `.atelier-pane`, `.atelier-resizer*` :
```css
.atelier-shell { background: #0a1722; color: #e8eef3; font-family: 'IBM Plex Mono',ui-monospace,monospace; }
.atelier-pane { background: #0f2233; border: 1px solid #1c3b52; }
.atelier-resizer { background: #1c3b52; }
.atelier-resizer:hover { background: #FF8800; }
/* légère grille de fond façon plan de travail */
.atelier-shell::before {
content: ''; position: fixed; inset: 0; pointer-events: none; opacity: 0.05;
background-image: linear-gradient(#FF8800 1px,transparent 1px),linear-gradient(90deg,#FF8800 1px,transparent 1px);
background-size: 32px 32px;
}
```
- [ ] **Step 3: Build vert**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS (les fonts apparaissent comme assets).
### Task B3: Barre-titre labo (MenuBar)
**Files:**
- Modify: `src/components/MenuBar.tsx`
- [ ] **Step 1: Re-skin de la barre**
Ajouter à gauche un titre `▦ ZACUS · ATELIER` en `WB.fontTitle`, couleur `WB.orange`, letter-spacing 2px ; fond `WB.panel`, bordure basse `WB.panelBorder`. Conserver toutes les actions existantes (ouvrir/charger/enregistrer/partager) — seulement restyler (boutons : fond `WB.panel`, bordure `WB.panelBorder`, texte `#e8eef3`, hover bordure `WB.orange`). Importer `WB` depuis `../theme/workbench.js`.
- [ ] **Step 2: Build vert**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS.
### Task B4: Thème Blockly Workbench
**Files:**
- Modify: `src/components/BlocklyWorkspace.tsx`
- [ ] **Step 1: Définir + appliquer un thème Blockly**
Avant `Blockly.inject`, créer un thème et le passer dans les options :
```ts
import * as Blockly from 'blockly';
const workbenchTheme = Blockly.Theme.defineTheme('zacus-workbench', {
name: 'zacus-workbench',
base: Blockly.Themes.Classic,
componentStyles: {
workspaceBackgroundColour: '#0a1722',
toolboxBackgroundColour: '#0f2233',
toolboxForegroundColour: '#e8eef3',
flyoutBackgroundColour: '#0f2233',
flyoutForegroundColour: '#AAAAAA',
scrollbarColour: '#1c3b52',
insertionMarkerColour: '#FF8800',
gridColour: '#1c3b52',
},
fontStyle: { family: "'IBM Plex Mono',monospace", size: 11 },
});
// dans les options d'inject : theme: workbenchTheme, grid: { spacing: 24, colour: '#13283a', snap: true }
```
- [ ] **Step 2: Build vert + sanity**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS. (Vérif visuelle ultérieure.)
- [ ] **Step 3: Commit phase B**
```bash
cd ../../.. && git add -A
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(atelier): Workbench theme (palette firmware, Orbitron/IBM Plex Mono, Blockly theme)"
```
---
## PHASE C — Banc d'essai (Écran + Flux)
### Task C1: Extraire le rendu LCD fidèle réutilisable
**Files:**
- Create: `src/components/DeviceScreenView.tsx`
- Modify: `src/components/ScreenEditorPanel.tsx` (importer la vue partagée)
- [ ] **Step 1: Créer `DeviceScreenView` à partir du `ScreenPreview` de P3a**
Déplacer le composant `ScreenPreview` (et `symbolAnimation`) de `ScreenEditorPanel.tsx` vers un fichier partagé, paramétré par l'échelle :
```tsx
// src/components/DeviceScreenView.tsx
import { SCREEN, type SceneDraft, type ScreenEffect } from '../lib/screenSpec.js';
function symbolAnimation(effect: ScreenEffect): string | undefined {
if (effect === 'pulse') return 'zacus-pulse 2.4s ease-in-out infinite';
if (effect === 'glitch') return 'zacus-glitch 0.45s steps(2) infinite';
return undefined;
}
export function DeviceScreenView({ scene, scale = 0.66 }: { scene: SceneDraft; scale?: number }) {
return (
<div style={{ width: SCREEN.width * scale, height: SCREEN.height * scale, alignSelf: 'center' }}>
<div style={{
width: SCREEN.width, height: SCREEN.height, transform: `scale(${scale})`, transformOrigin: 'top left',
background: SCREEN.bg, position: 'relative', borderRadius: 6, overflow: 'hidden',
boxShadow: 'inset 0 0 0 2px rgba(0,0,0,0.4)',
}}>
<div style={{ position: 'absolute', top: 24, left: 12, right: 12, textAlign: 'center',
color: SCREEN.title.color, font: `700 40px ${SCREEN.title.font}`, letterSpacing: 2,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{scene.title}</div>
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{scene.effect === 'gyro' && scene.symbol && (
<span style={{ position: 'absolute', width: 120, height: 120, borderRadius: '50%',
border: `3px solid ${SCREEN.symbol.color}`, borderTopColor: 'transparent',
animation: 'zacus-gyro-spin 3s linear infinite' }} />
)}
<span style={{ color: SCREEN.symbol.color, font: `700 40px ${SCREEN.symbol.font}`, letterSpacing: 3,
animation: symbolAnimation(scene.effect) }}>{scene.symbol}</span>
</div>
<div style={{ position: 'absolute', bottom: 10, left: 8, right: 8,
color: SCREEN.subtitle.color, font: `18px ${SCREEN.subtitle.font}`,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{scene.subtitle}</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Réutiliser dans `ScreenEditorPanel`**
Dans `ScreenEditorPanel.tsx`, supprimer le `ScreenPreview`/`symbolAnimation` locaux et importer `import { DeviceScreenView } from './DeviceScreenView.js';` ; remplacer `<ScreenPreview scene={scene} />` par `<DeviceScreenView scene={scene} />`.
- [ ] **Step 3: Typecheck + build**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS.
### Task C2: selectionStore
**Files:**
- Create: `src/stores/selectionStore.ts`
- [ ] **Step 1: Créer le store**
```ts
// src/stores/selectionStore.ts
import { create } from 'zustand';
interface SelectionState {
selectedStepId: string | null;
selectStep: (id: string | null) => void;
}
export const useSelectionStore = create<SelectionState>((set) => ({
selectedStepId: null,
selectStep: (selectedStepId) => set({ selectedStepId }),
}));
```
- [ ] **Step 2: Typecheck**
Run: `pnpm exec tsc --noEmit`
Expected: PASS.
### Task C3: Parser IR → steps + edges
**Files:**
- Create: `src/lib/irSteps.ts`
- [ ] **Step 1: Écrire le parseur**
```ts
// src/lib/irSteps.ts
// Parse le currentIr (string JSON) du runtimeStore en nœuds/arêtes pour le FlowGraph.
import type { SceneDraft } from './screenSpec.js';
export interface FlowNode {
id: string;
scene: SceneDraft; // title/subtitle/symbol/effect (defaults vides + effect 'pulse')
}
export interface FlowEdge { from: string; to: string; }
export interface FlowGraphData { nodes: FlowNode[]; edges: FlowEdge[]; entry: string | null; }
interface RawStep {
id?: string;
scene?: { title?: string; subtitle?: string; symbol?: string; effect?: SceneDraft['effect'] };
transitions?: { target_step_id?: string }[];
}
export function parseIr(irJson: string | null): FlowGraphData {
if (!irJson) return { nodes: [], edges: [], entry: null };
let raw: { entry_step_id?: string; steps?: RawStep[] };
try { raw = JSON.parse(irJson); } catch { return { nodes: [], edges: [], entry: null }; }
const steps = raw.steps ?? [];
const nodes: FlowNode[] = steps.filter((s) => s.id).map((s) => ({
id: s.id as string,
scene: {
title: s.scene?.title ?? '',
subtitle: s.scene?.subtitle ?? '',
symbol: s.scene?.symbol ?? '',
effect: s.scene?.effect ?? 'pulse',
},
}));
const ids = new Set(nodes.map((n) => n.id));
const edges: FlowEdge[] = [];
for (const s of steps) {
if (!s.id) continue;
for (const t of s.transitions ?? []) {
if (t.target_step_id && ids.has(t.target_step_id)) edges.push({ from: s.id, to: t.target_step_id });
}
}
return { nodes, edges, entry: raw.entry_step_id ?? nodes[0]?.id ?? null };
}
```
- [ ] **Step 2: Typecheck**
Run: `pnpm exec tsc --noEmit`
Expected: PASS.
### Task C4: FlowGraph (liste ordonnée cliquable + arêtes)
**Files:**
- Create: `src/components/FlowGraph.tsx`
- [ ] **Step 1: Écrire le composant (V1 : colonne de nœuds + libellés de transition)**
```tsx
// src/components/FlowGraph.tsx
import { useMemo } from 'react';
import { useRuntimeStore } from '../stores/runtimeStore.js';
import { useSelectionStore } from '../stores/selectionStore.js';
import { parseIr } from '../lib/irSteps.js';
import { WB } from '../theme/workbench.js';
export function FlowGraph() {
const ir = useRuntimeStore((s) => s.currentIr ?? s.pendingIr);
const { selectedStepId, selectStep } = useSelectionStore();
const { nodes, edges, entry } = useMemo(() => parseIr(ir), [ir]);
const outgoing = (id: string) => edges.filter((e) => e.from === id).map((e) => e.to);
if (nodes.length === 0) {
return <div style={{ color: WB.label, padding: 8, fontFamily: WB.fontMono }}>Aucune étape édite le scénario puis lance (Run).</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 8, overflowY: 'auto', fontFamily: WB.fontMono, fontSize: 12 }}>
{nodes.map((n) => {
const sel = n.id === selectedStepId;
return (
<div key={n.id}>
<button type="button" onClick={() => selectStep(n.id)}
style={{ width: '100%', textAlign: 'left', padding: '6px 8px', cursor: 'pointer',
background: sel ? WB.orange : WB.panel, color: sel ? '#1a1a1d' : '#e8eef3',
border: `1px solid ${sel ? WB.orange : WB.panelBorder}`, borderRadius: 5 }}>
{n.id === entry ? '▶ ' : ''}{n.id}
{n.scene.symbol ? <span style={{ color: sel ? '#1a1a1d' : WB.orange }}> {n.scene.symbol}</span> : null}
</button>
{outgoing(n.id).map((to) => (
<div key={to} style={{ color: WB.label, paddingLeft: 16 }}> {to}</div>
))}
</div>
);
})}
</div>
);
}
```
- [ ] **Step 2: Typecheck + build**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS.
### Task C5: DeviceScreen (écran de l'étape sélectionnée)
**Files:**
- Create: `src/components/DeviceScreen.tsx`
- [ ] **Step 1: Écrire le composant**
```tsx
// src/components/DeviceScreen.tsx
import { useMemo } from 'react';
import { useRuntimeStore } from '../stores/runtimeStore.js';
import { useSelectionStore } from '../stores/selectionStore.js';
import { parseIr } from '../lib/irSteps.js';
import { EMPTY_SCENE } from '../lib/screenSpec.js';
import { DeviceScreenView } from './DeviceScreenView.js';
import { WB } from '../theme/workbench.js';
export function DeviceScreen() {
const ir = useRuntimeStore((s) => s.currentIr ?? s.pendingIr);
const selectedStepId = useSelectionStore((s) => s.selectedStepId);
const { nodes, entry } = useMemo(() => parseIr(ir), [ir]);
const node = nodes.find((n) => n.id === selectedStepId) ?? nodes.find((n) => n.id === entry) ?? null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 8, alignItems: 'center' }}>
<span style={{ color: WB.label, fontFamily: WB.fontMono, fontSize: 11, alignSelf: 'flex-start' }}>
ÉCRAN · {node ? node.id : '—'}
</span>
<DeviceScreenView scene={node ? node.scene : EMPTY_SCENE} scale={0.7} />
</div>
);
}
```
- [ ] **Step 2: Typecheck + build**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS.
### Task C6: WorkbenchPane (split vertical) + branchement Layout
**Files:**
- Create: `src/components/WorkbenchPane.tsx`
- Modify: `src/components/Layout.tsx`
- [ ] **Step 1: Écrire le split vertical**
```tsx
// src/components/WorkbenchPane.tsx
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { DeviceScreen } from './DeviceScreen.js';
import { FlowGraph } from './FlowGraph.js';
import { WB } from '../theme/workbench.js';
export function WorkbenchPane() {
return (
<div className="atelier-pane" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '4px 8px', color: WB.orange, fontFamily: WB.fontTitle, letterSpacing: 1,
borderBottom: `1px solid ${WB.panelBorder}`, fontSize: 12 }}>BANC D'ESSAI</div>
<PanelGroup direction="vertical" style={{ flex: 1 }}>
<Panel defaultSize={45} minSize={20}><DeviceScreen /></Panel>
<PanelResizeHandle className="atelier-resizer atelier-resizer--horizontal" />
<Panel defaultSize={55} minSize={20}><FlowGraph /></Panel>
</PanelGroup>
</div>
);
}
```
- [ ] **Step 2: Brancher dans Layout (remplacer le placeholder de A2)**
Dans `src/components/Layout.tsx` : `import { WorkbenchPane } from './WorkbenchPane.js';` et remplacer le `<div ...>Banc d'essai (à venir)</div>` par `<WorkbenchPane />`.
- [ ] **Step 3: Typecheck + build**
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
Expected: PASS.
- [ ] **Step 4: Commit phase C**
```bash
cd ../../.. && git add -A
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(atelier): Workbench pane — faithful device screen + scenario flow graph"
```
---
## Self-review (couverture spec)
- Retrait 3D (spec §4 supprimés) → Phase A ✓
- Thème Workbench palette/fonts/chrome/Blockly (spec §3) → Phase B ✓
- DeviceScreen réutilisant P3a (spec §4 nouveaux) → C1/C5 ✓
- FlowGraph depuis IR (spec §5) → C3/C4 ✓
- selectionStore + wiring (spec §5) → C2, FlowGraph/DeviceScreen lisent `selectionStore`
- Split vertical (spec §2, décision) → C6 ✓
- Tests = tsc+build verts par tâche (spec §7) ✓
- Hors périmètre (Blockly conservé, pas de playtest, pas dashboard) respecté ✓
## Notes d'exécution
- Le `FlowGraph` V1 est une **liste ordonnée de nœuds + arêtes sortantes** (le plus sûr) ; un layout graphe spatial (positions/SVG) est une amélioration ultérieure hors de ce plan.
- Vérif visuelle finale recommandée via `pnpm --filter @zacus/atelier dev` (hors CI) après C6.
- Déploiement prod (`ops/deploy-frontend.sh atelier`) **hors de ce plan** — à faire après revue visuelle.
@@ -0,0 +1,948 @@
# PLIP — Téléphone conversationnel France Télécom — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Faire du PLIP un téléphone France Télécom interactif : décroché → tonalité → numérotation (cadran/DTMF) → annuaire → PNJ vocal conversationnel (Zacus & services d'époque).
**Architecture:** Le firmware PLIP possède la machine à états téléphonique ; la gateway expose `POST /v1/voice/turn` (stateless/tour) qui enchaîne whisper(STT) → ailiance(chat) → ailiance(TTS) → resample 16k. Le PLIP ne fait ni TLS, ni prompt, ni resample.
**Tech Stack:** ESP-IDF (esp32, plip_voice : net.c httpd, audio.c I2S/ES8388, phone.c hook), SLIC classe AG1171 (SHK = hook+impulsions). Gateway FastAPI/httpx (`tools/zacus-gateway/main.py`), pytest. TTS+LLM via `gateway.ailiance.fr` (OpenAI-compatible).
**Spec:** `docs/superpowers/specs/2026-06-14-plip-telephone-conversation-design.md`
**Référence (numéros de ligne au moment de la rédaction) :** patterns dans `ESP32_ZACUS/plip_voice/main/{net.c,audio.c,phone.c,cmd_exec.c,hook_client.c,Kconfig.projbuild,board_config.h,CMakeLists.txt}` et `tools/zacus-gateway/main.py` (`_synthesise_wav` ~l.701, `_wav_to_16k_mono` ~l.734, route `/v1/voice/say` ~l.1358).
**Convention build/flash firmware :** `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`. Série : `/dev/cu.usbserial-0001` (un lecteur à la fois). PLIP IP 192.168.0.138.
**Convention commits firmware (submodule) :** `git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "..."`. JAMAIS `--no-verify`. Commits gateway : depuis la racine repo.
---
## STAGE 1 — Tonalités + dialer + machine à états (firmware, zéro IA)
Livrable : décroché → tonalité 440 Hz ; composer un numéro (debug HTTP + impulsions SHK) → ringback si numéro connu / occupé sinon ; raccroché = mute. Aucune conversation encore.
### Task 1.1 : Module `tones` (tonalités France Télécom)
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/tones.h`
- Create: `ESP32_ZACUS/plip_voice/main/tones.c`
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt` (ajouter `tones.c` aux SRCS)
- [ ] **Step 1 : Écrire `tones.h`**
```c
#pragma once
#include "esp_err.h"
// Tonalités France Télécom — générées via le DAC/I2S, non bloquant (tâche dédiée).
// Toutes forcent le PA on (audio_pa_set(true)) le temps de jouer.
void tones_dialtone_start(void); // 440 Hz continu (invitation à numéroter)
void tones_busy_start(void); // 440 Hz 0,5 s on / 0,5 s off (occupé / non attribué)
void tones_ringback_start(void); // 440 Hz 1,5 s on / 3,5 s off (retour d'appel)
void tones_stop(void); // arrête toute tonalité en cours
```
- [ ] **Step 2 : Écrire `tones.c`**
Génère un sinus 440 Hz en PCM 16 kHz mono et l'écrit en boucle sur l'I2S TX (`audio_spk_handle()`), avec un masque on/off selon la tonalité. Une seule tâche FreeRTOS `s_tone_task`, un `volatile tone_mode_t s_mode` (NONE/DIAL/BUSY/RINGBACK). `tones_stop` met NONE + `audio_pa_set` géré par l'appelant (la machine à états). Modèle : la génération de tone dans `audio.c::audio_play_tone` (sinus + `i2s_channel_write` sur `audio_spk_handle()`).
```c
#include "tones.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2s_std.h"
#include <math.h>
#include <string.h>
typedef enum { TONE_NONE, TONE_DIAL, TONE_BUSY, TONE_RINGBACK } tone_mode_t;
static volatile tone_mode_t s_mode = TONE_NONE;
static TaskHandle_t s_task;
#define SR 16000
#define FRAME 320 // 20 ms
static void fill_440(int16_t *buf, int n, int *phase) {
for (int i = 0; i < n; i++) {
buf[i] = (int16_t)(8000.0f * sinf(2.0f * (float)M_PI * 440.0f * (*phase) / SR));
(*phase)++;
}
}
static void tone_task(void *arg) {
int16_t buf[FRAME];
int phase = 0;
int64_t t = 0; // ms écoulées dans le cycle on/off
for (;;) {
tone_mode_t m = s_mode;
if (m == TONE_NONE) { vTaskDelay(pdMS_TO_TICKS(20)); t = 0; continue; }
bool on = true;
if (m == TONE_BUSY) on = (t % 1000) < 500;
else if (m == TONE_RINGBACK) on = (t % 5000) < 1500;
if (on) fill_440(buf, FRAME, &phase);
else memset(buf, 0, sizeof(buf));
size_t w;
i2s_channel_write(audio_spk_handle(), buf, sizeof(buf), &w, portMAX_DELAY);
t += 20;
}
}
static void ensure_task(void) {
if (!s_task) xTaskCreate(tone_task, "tones", 3072, NULL, 5, &s_task);
}
void tones_dialtone_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_DIAL; }
void tones_busy_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_BUSY; }
void tones_ringback_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_RINGBACK; }
void tones_stop(void){ s_mode = TONE_NONE; }
```
- [ ] **Step 3 : Ajouter `tones.c` au CMakeLists**
Dans `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`, ajouter `"tones.c"` dans `SRCS` (à côté de `"audio.c"`).
- [ ] **Step 4 : Build**
Run: `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build`
Expected: build vert, `tones.c` compilé.
- [ ] **Step 5 : Commit**
```bash
git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -am "feat(plip): tones module — France Télécom dial/busy/ringback"
```
### Task 1.2 : Module `dialer` (numéro composé : debug + impulsions SHK)
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/dialer.h`
- Create: `ESP32_ZACUS/plip_voice/main/dialer.c`
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`
- Modify: `ESP32_ZACUS/plip_voice/main/net.c` (handler `/debug/dial`)
- [ ] **Step 1 : Écrire `dialer.h`**
```c
#pragma once
#include "esp_err.h"
#include <stdbool.h>
// Le dialer accumule des chiffres (source : impulsions SHK, DTMF, ou /debug/dial)
// et expose le numéro courant. La machine à états (conversation.c) lit
// dialer_current()/dialer_complete() et appelle dialer_reset() à la fin d'un appel.
void dialer_init(void);
void dialer_push_digit(int d); // 0..9 ; appelé par les sources (debug, pulse, dtmf)
void dialer_reset(void); // vide le numéro (raccroché / fin d'appel)
const char *dialer_current(void); // ex. "17" (string null-terminée, max 8)
bool dialer_idle(void); // true si aucun chiffre depuis le reset
int dialer_ms_since_last(void); // ms depuis le dernier chiffre (pour timeout inter-chiffre)
```
- [ ] **Step 2 : Écrire `dialer.c`** (accumulateur + horodatage)
```c
#include "dialer.h"
#include "freertos/FreeRTOS.h"
#include "esp_timer.h"
#include <string.h>
static char s_num[9];
static int s_len;
static int64_t s_last_us;
void dialer_init(void){ dialer_reset(); }
void dialer_reset(void){ s_num[0]=0; s_len=0; s_last_us=0; }
void dialer_push_digit(int d){
if (d<0||d>9||s_len>=8) return;
s_num[s_len++] = (char)('0'+d); s_num[s_len]=0;
s_last_us = esp_timer_get_time();
}
const char *dialer_current(void){ return s_num; }
bool dialer_idle(void){ return s_len==0; }
int dialer_ms_since_last(void){
if (!s_last_us) return 0;
return (int)((esp_timer_get_time()-s_last_us)/1000);
}
```
- [ ] **Step 3 : Handler `/debug/dial?number=NNNN` dans `net.c`**
Calque le pattern `handle_status`/`handle_cmd_post` (net.c:127, 407). Parse le query `number`, appelle `dialer_push_digit` pour chaque chiffre. Enregistre `uri_debug_dial` (HTTP_GET) et **incrémente `cfg.max_uri_handlers`** (actuellement 12 → 14, on ajoutera aussi `/debug/onhook` et plus tard rien d'autre côté net). Include `dialer.h`.
```c
static esp_err_t handle_debug_dial(httpd_req_t *req) {
char q[64], val[16];
if (httpd_req_get_url_query_str(req, q, sizeof(q)) == ESP_OK &&
httpd_query_key_value(q, "number", val, sizeof(val)) == ESP_OK) {
for (const char *p = val; *p; p++) if (*p>='0'&&*p<='9') dialer_push_digit(*p-'0');
return send_json(req, "200 OK", "{\"ok\":true}");
}
return send_json(req, "400 Bad Request", "{\"error\":\"number?\"}");
}
static const httpd_uri_t uri_debug_dial = {
.uri="/debug/dial", .method=HTTP_GET, .handler=handle_debug_dial, .user_ctx=NULL };
// dans la fonction d'enregistrement : httpd_register_uri_handler(s_httpd, &uri_debug_dial);
```
- [ ] **Step 4 : Ajouter `dialer.c` au CMakeLists + build**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build`
Expected: vert.
- [ ] **Step 5 : Flash + test debug dial**
```bash
idf.py -p /dev/cu.usbserial-0001 flash
curl -s "http://192.168.0.138/debug/dial?number=17" # -> {"ok":true}
```
Expected: `{"ok":true}`, série logue les chiffres (ajouter un `ESP_LOGI` dans push_digit pour vérifier).
- [ ] **Step 6 : Commit**
```bash
git -C ESP32_ZACUS ... commit -am "feat(plip): dialer module + /debug/dial (digit accumulator)"
```
### Task 1.3 : Impulsions cadran sur SHK (comptage)
**Files:**
- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (compter les impulsions sur le GPIO hook = SHK)
- Modify: `ESP32_ZACUS/plip_voice/main/Kconfig.projbuild` (option pulse)
- [ ] **Step 1 : Kconfig pulse**
Dans `Kconfig.projbuild`, ajouter sous le menu PLIP :
```
config PLIP_DIAL_PULSE
bool "Enable rotary pulse dialing on the hook/SHK GPIO"
default y
config PLIP_DIAL_PULSE_MAX_GAP_MS
int "Inter-pulse max gap (ms) — above this = digit complete"
depends on PLIP_DIAL_PULSE
default 200
```
- [ ] **Step 2 : Compter les impulsions dans `phone.c`**
Le cadran génère des ouvertures/fermetures rapides de la boucle pendant la composition (le SHK pulse), distinctes du décroché/raccroché (état stable). Dans la task hook (phone.c:86-111) : si `PLIP_DIAL_PULSE` et qu'on est **off-hook**, compter les transitions rapides ; quand le gap depuis la dernière impulsion dépasse `CONFIG_PLIP_DIAL_PULSE_MAX_GAP_MS`, le nombre d'impulsions = le chiffre (10 impulsions = 0) → `dialer_push_digit`. Garder la détection on/off-hook stable (raccroché = boucle ouverte >~500 ms). Include `dialer.h`.
```c
// pseudo-intégration dans la task hook, après lecture du niveau débouncé :
#if CONFIG_PLIP_DIAL_PULSE
static int s_pulse_count = 0;
static int64_t s_last_pulse_us = 0;
// sur front descendant bref (impulsion) pendant off-hook : s_pulse_count++; s_last_pulse_us=now;
// périodiquement : si s_pulse_count>0 && (now - s_last_pulse_us) > gap_ms :
// int digit = (s_pulse_count==10)?0:s_pulse_count; dialer_push_digit(digit); s_pulse_count=0;
#endif
```
(Note implémenteur : le débounce hook actuel 30 ms peut masquer les impulsions cadran ~ 60-100 ms ; ajuster la logique pour distinguer impulsion brève vs raccroché stable. Tester au banc avec un vrai cadran ; sans cadran, `/debug/dial` couvre la suite.)
- [ ] **Step 3 : Build + flash**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
Expected: vert ; au banc avec cadran (si dispo) composer "1" → série logue digit=1. Sans cadran : skip, `/debug/dial` reste la voie de test.
- [ ] **Step 4 : Commit**
```bash
git -C ESP32_ZACUS ... commit -am "feat(plip): rotary pulse dialing on SHK GPIO"
```
### Task 1.4 : Machine à états `conversation` jusqu'à RINGBACK/BUSY
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/conversation.h`
- Create: `ESP32_ZACUS/plip_voice/main/conversation.c`
- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (notifier conversation des transitions hook)
- Modify: `ESP32_ZACUS/plip_voice/main/main.c` (init conversation)
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`
- [ ] **Step 1 : Écrire `conversation.h`**
```c
#pragma once
#include <stdbool.h>
void conversation_init(void);
void conversation_on_hook_change(bool offhook); // appelé par phone.c
// Stage 1 : IDLE → DIAL_TONE → DIALING → ROUTING → RINGBACK|BUSY → (Stage 2: CONNECT…)
```
- [ ] **Step 2 : Écrire `conversation.c`** (tâche d'état, Stage 1)
Tâche FreeRTOS qui poll l'état. Au décroché : `tones_dialtone_start()`. Dès qu'un chiffre apparaît (`!dialer_idle()`) : `tones_stop()`, passe en DIALING. Quand `dialer_ms_since_last() > 3000` (timeout inter-chiffre) OU le numéro matche une entrée connue : ROUTING. Routing Stage 1 = table locale des numéros valides (`"12","3615","15","17","18"` + Zacus) ; connu → `tones_ringback_start()` (2 s puis stop, Stage 2 enchaînera CONNECT) ; inconnu → `tones_busy_start()` (5 s) → IDLE. Au raccroché : `tones_stop()`, `audio_stop()`, `audio_pa_set(false)`, `dialer_reset()`, IDLE.
```c
#include "conversation.h"
#include "tones.h"
#include "dialer.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <string.h>
static const char *TAG = "conv";
typedef enum { ST_IDLE, ST_DIALTONE, ST_DIALING, ST_RINGBACK, ST_BUSY } st_t;
static volatile bool s_offhook;
static volatile st_t s_st = ST_IDLE;
static const char *KNOWN[] = { "12","3615","15","17","18", NULL };
static bool number_known(const char *n){
for (int i=0; KNOWN[i]; i++) if (!strcmp(n, KNOWN[i])) return true;
return false;
}
static void conv_task(void *arg){
for (;;){
vTaskDelay(pdMS_TO_TICKS(50));
if (!s_offhook){
if (s_st != ST_IDLE){ tones_stop(); audio_stop(); audio_pa_set(false); dialer_reset(); s_st=ST_IDLE; }
continue;
}
switch (s_st){
case ST_IDLE: tones_dialtone_start(); s_st=ST_DIALTONE; break;
case ST_DIALTONE:
if (!dialer_idle()){ tones_stop(); s_st=ST_DIALING; }
break;
case ST_DIALING: {
const char *n = dialer_current();
if (number_known(n)){
ESP_LOGI(TAG, "route %s -> known", n);
tones_ringback_start(); s_st=ST_RINGBACK; // Stage 2: enchaîner CONNECT
} else if (dialer_ms_since_last() > 3000){
ESP_LOGI(TAG, "route %s -> unknown", n);
tones_busy_start(); s_st=ST_BUSY;
}
break; }
case ST_RINGBACK: /* Stage 2 remplira CONNECT/GREET ici */ break;
case ST_BUSY: break; // reste occupé jusqu'au raccroché
}
}
}
void conversation_init(void){ xTaskCreate(conv_task,"conv",4096,NULL,5,NULL); }
void conversation_on_hook_change(bool offhook){ s_offhook = offhook; if(!offhook) s_st=ST_IDLE; }
```
- [ ] **Step 3 : Brancher phone.c → conversation**
Dans la task hook de `phone.c` (là où `hook_client_report` est appelé), ajouter `conversation_on_hook_change(offhook)` (offhook = `phone_is_offhook()`). Include `conversation.h`.
- [ ] **Step 4 : Init dans main.c**
Dans `ESP32_ZACUS/plip_voice/main/main.c`, après `audio_init()` et l'init réseau, ajouter `dialer_init(); conversation_init();`. Include les headers.
- [ ] **Step 5 : Ajouter conversation.c au CMakeLists + build + flash**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
- [ ] **Step 6 : Test au banc (séquence complète Stage 1)**
Décroché (ou simuler off-hook) → tonalité au HP. Puis :
```bash
curl -s "http://192.168.0.138/debug/dial?number=17" # numéro connu
# série attendue : "route 17 -> known" + ringback au HP
curl -s "http://192.168.0.138/debug/dial?number=99" # après reset/raccroché
# série attendue : "route 99 -> unknown" + busy au HP (après timeout 3s)
```
Raccroché → silence (PA off). Expected : tonalité → ringback (connu) / occupé (inconnu) → silence au raccroché.
- [ ] **Step 7 : Commit**
```bash
git -C ESP32_ZACUS ... commit -am "feat(plip): conversation state machine — dialtone/dialing/routing/ringback/busy (stage 1)"
```
### Task 1.5 : Lander Stage 1 (PR submodule + bump pointeur)
- [ ] **Step 1 : PR submodule ESP32_ZACUS** (branche `feat/plip-phone-stage1`), merge via API Gitea (voir conventions de session : push avec token, PR, merge, ff-only main).
- [ ] **Step 2 : Bump pointeur** dans le superprojet (`git add ESP32_ZACUS`, commit `chore: bump ESP32_ZACUS — PLIP phone stage 1`, PR, merge).
---
## STAGE 2 — Gateway `/v1/voice/turn` (voie greeting) + annuaire (téléphone jouable sens unique)
Livrable : composer un numéro → **entendre le PNJ débiter son accueil** (TTS ailiance). Sans micro ni whisper.
### Task 2.1 : Annuaire `phone_directory.yaml` + chargement gateway
**Files:**
- Create: `game/scenarios/phone_directory.yaml`
- Modify: `tools/zacus-gateway/main.py` (loader + Settings)
- Test: `tests/gateway/test_phone_directory.py`
- [ ] **Step 1 : Écrire l'annuaire**
```yaml
# game/scenarios/phone_directory.yaml — numéro → persona vocale
# persona = prompt système (personnage) ; voice = voix ailiance ; greeting = 1re réplique forcée (optionnel)
numbers:
"12":
label: "Renseignements"
voice: "nova"
persona: >
Tu es l'opératrice des renseignements téléphoniques de France Télécom, années 80.
Tu es polie, un peu pressée. Tu orientes l'appelant vers les bons services sans
jamais donner directement la solution d'une énigme.
greeting: "Renseignements, bonjour. Quel numéro demandez-vous ?"
"15":
label: "SAMU"
voice: "nova"
persona: >
Tu es un régulateur du SAMU. Tu réponds avec calme et autorité. Dans cet escape game
tu peux donner un indice médical déguisé, mais jamais la réponse directe.
greeting: "SAMU, j'écoute, quelle est votre urgence ?"
"17":
label: "Police secours"
voice: "alloy"
persona: >
Tu es un agent de Police secours, factuel et sérieux. Indices déguisés, jamais la solution.
greeting: "Police secours, je vous écoute."
"18":
label: "Pompiers"
voice: "alloy"
persona: >
Tu es un sapeur-pompier, direct et rassurant. Indices déguisés, jamais la solution.
greeting: "Sapeurs-pompiers, bonjour, quel est le problème ?"
"3615":
label: "Minitel"
voice: "nova"
minitel: true
persona: >
Tu es un service Minitel automatisé (3615). Tu parles de façon datée, robotique,
télématique. Réponses courtes façon serveur télétel.
greeting: "Trois mille six cent quinze. Service en ligne. Tapez votre requête."
"0142738200":
label: "Professeur Zacus"
voice: "nova"
persona: >
Tu es le Professeur Zacus, savant excentrique et théâtral. Tu guides les joueurs
par des indices déguisés et des énigmes, jamais la solution directe. Tu connais le
scénario en cours mais restes mystérieux.
greeting: "Ah... vous avez réussi à me joindre. Ici le Professeur Zacus."
```
- [ ] **Step 2 : Écrire le test (échoue d'abord)**
`tests/gateway/test_phone_directory.py` :
```python
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
def test_directory_loads_known_numbers():
d = main.load_phone_directory()
assert "17" in d and d["17"]["label"] == "Police secours"
assert "3615" in d and d["3615"].get("minitel") is True
assert "voice" in d["12"] and "persona" in d["12"]
def test_directory_unknown_number_absent():
d = main.load_phone_directory()
assert "99" not in d
```
- [ ] **Step 3 : Lancer le test → échoue**
Run: `cd /Users/electron/code/zacus && python3 -m pytest tests/gateway/test_phone_directory.py -v`
Expected: FAIL (`load_phone_directory` n'existe pas).
- [ ] **Step 4 : Implémenter le loader**
Dans `tools/zacus-gateway/main.py`, près du `_load_boards_registry` (~l.32) :
```python
def load_phone_directory() -> dict:
import yaml
p = SCENARIOS_DIR / "phone_directory.yaml" # SCENARIOS_DIR = REPO_ROOT/game/scenarios
if not p.is_file():
return {}
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return data.get("numbers", {})
PHONE_DIRECTORY = load_phone_directory()
```
- [ ] **Step 5 : Lancer le test → passe**
Run: `python3 -m pytest tests/gateway/test_phone_directory.py -v`
Expected: PASS (2 tests).
- [ ] **Step 6 : Commit**
```bash
git add game/scenarios/phone_directory.yaml tools/zacus-gateway/main.py tests/gateway/test_phone_directory.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): phone directory loader (numéro → persona/voix)"
```
### Task 2.2 : Sessions en mémoire (historique conversation)
**Files:**
- Modify: `tools/zacus-gateway/main.py`
- Test: `tests/gateway/test_voice_session.py`
- [ ] **Step 1 : Test (échoue)**
```python
import sys, time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
def test_session_appends_and_caps():
s = main.VoiceSessions(ttl_s=600, max_turns=8)
s.append("sid1", "user", "bonjour")
s.append("sid1", "assistant", "ici Zacus")
msgs = s.history("sid1")
assert msgs[-2:] == [{"role":"user","content":"bonjour"},
{"role":"assistant","content":"ici Zacus"}]
for i in range(40): s.append("sid1", "user", f"m{i}")
assert len(s.history("sid1")) <= 16 # 8 tours * 2
def test_session_end_clears():
s = main.VoiceSessions()
s.append("sid2","user","x"); s.end("sid2")
assert s.history("sid2") == []
```
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → FAIL.
- [ ] **Step 3 : Implémenter `VoiceSessions`** dans `main.py` :
```python
class VoiceSessions:
def __init__(self, ttl_s: int = 600, max_turns: int = 8):
self._d: dict[str, dict] = {}
self._ttl = ttl_s
self._max = max_turns * 2
def append(self, sid: str, role: str, content: str) -> None:
import time
e = self._d.setdefault(sid, {"msgs": [], "ts": 0.0})
e["msgs"].append({"role": role, "content": content})
e["msgs"] = e["msgs"][-self._max:]
e["ts"] = time.time()
def history(self, sid: str) -> list[dict]:
import time
e = self._d.get(sid)
if not e: return []
if time.time() - e["ts"] > self._ttl:
self._d.pop(sid, None); return []
return list(e["msgs"])
def end(self, sid: str) -> None:
self._d.pop(sid, None)
VOICE_SESSIONS = VoiceSessions()
```
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → PASS.
- [ ] **Step 5 : Commit**
```bash
git add tools/zacus-gateway/main.py tests/gateway/test_voice_session.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): in-memory voice sessions (history + TTL)"
```
### Task 2.3 : LLM chat helper (ailiance)
**Files:**
- Modify: `tools/zacus-gateway/main.py` (Settings + `_chat_reply`)
- Test: `tests/gateway/test_chat_reply.py`
- [ ] **Step 1 : Test (mock httpx)**
```python
import sys, asyncio
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
class FakeResp:
status_code = 200
def json(self): return {"choices":[{"message":{"content":"Ici Zacus."}}]}
class FakeHTTP:
async def post(self, *a, **k): return FakeResp()
def test_chat_reply_returns_content():
out = asyncio.run(main._chat_reply(FakeHTTP(), "persona", [{"role":"user","content":"allo"}]))
assert out == "Ici Zacus."
```
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → FAIL.
- [ ] **Step 3 : Implémenter** dans `main.py` (Settings : `ailiance_chat_model: str = "gpt-4o-mini"`) :
```python
async def _chat_reply(http, persona: str, history: list[dict]) -> str:
messages = [{"role": "system", "content": persona}] + history
resp = await http.post(
f"{settings.ailiance_tts_url.rstrip('/')}/v1/chat/completions",
json={"model": settings.ailiance_chat_model, "messages": messages, "max_tokens": 200},
timeout=60.0,
)
if resp.status_code != 200:
raise RuntimeError(f"chat HTTP {resp.status_code}")
return resp.json()["choices"][0]["message"]["content"].strip()
```
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → PASS.
- [ ] **Step 5 : Commit**
```bash
git add tools/zacus-gateway/main.py tests/gateway/test_chat_reply.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): ailiance chat helper for NPC replies"
```
### Task 2.4 : Route `/v1/voice/turn` (voie greeting) + `/v1/voice/end`
**Files:**
- Modify: `tools/zacus-gateway/main.py`
- Test: `tests/gateway/test_voice_turn.py`
- [ ] **Step 1 : Test voie greeting (mock chat + TTS)**
```python
import sys, asyncio
from pathlib import Path
from fastapi.testclient import TestClient
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
def test_turn_greeting_returns_wav(monkeypatch):
async def fake_chat(http, persona, history): return "Police secours, j'écoute."
async def fake_synth(http, text, voice):
# WAV 16k mono minuscule valide
import struct
pcm = b"\x00\x00" * 1600
hdr = b"RIFF" + struct.pack("<I", 36+len(pcm)) + b"WAVEfmt " + struct.pack("<IHHIIHH",16,1,1,16000,32000,2,16) + b"data" + struct.pack("<I", len(pcm))
return hdr + pcm, 0.1, False
monkeypatch.setattr(main, "_chat_reply", fake_chat)
monkeypatch.setattr(main, "_voice_tts_16k", fake_synth)
c = TestClient(main.app)
r = c.post("/v1/voice/turn",
headers={"Authorization": f"Bearer {main.settings.token}"},
json={"session_id":"s1","number":"17","kind":"greeting"})
assert r.status_code == 200
assert r.headers["content-type"] == "audio/wav"
assert r.headers.get("x-zacus-said") # texte de réponse en header
assert r.content[:4] == b"RIFF"
def test_turn_unknown_number_404():
c = TestClient(main.app)
r = c.post("/v1/voice/turn",
headers={"Authorization": f"Bearer {main.settings.token}"},
json={"session_id":"s1","number":"99","kind":"greeting"})
assert r.status_code == 404
```
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → FAIL.
- [ ] **Step 3 : Implémenter le helper TTS 16k + la route**
Helper (réutilise `_synthesise_wav` ailiance + `_wav_to_16k_mono`) :
```python
async def _voice_tts_16k(http, text: str, voice: str | None) -> tuple[bytes, float, bool]:
from types import SimpleNamespace
raw = await _synthesise_wav(http, SimpleNamespace(backend="ailiance", text=text, voice=voice))
return _wav_to_16k_mono(raw, max_seconds=7.5)
```
Route :
```python
from fastapi import Response
class VoiceTurnRequest(BaseModel):
session_id: str
number: str
kind: str = "reply" # "greeting" | "reply"
@app.post("/v1/voice/turn")
async def voice_turn(body: VoiceTurnRequest, request: Request, _: None = Depends(require_token)):
entry = PHONE_DIRECTORY.get(body.number)
if not entry:
raise HTTPException(404, f"numéro {body.number} non attribué")
persona = entry.get("persona", "")
voice = entry.get("voice")
http = request.app.state.http
heard = ""
if body.kind == "greeting":
said = entry.get("greeting") or await _chat_reply(http, persona, [])
else:
# Stage 3 remplira la transcription depuis le corps audio ; ici fallback vide
heard = ""
VOICE_SESSIONS.append(body.session_id, "user", heard or "(silence)")
said = await _chat_reply(http, persona, VOICE_SESSIONS.history(body.session_id))
VOICE_SESSIONS.append(body.session_id, "assistant", said)
wav16, _dur, _trunc = await _voice_tts_16k(http, said, voice)
return Response(content=wav16, media_type="audio/wav",
headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]})
class VoiceEndRequest(BaseModel):
session_id: str
@app.post("/v1/voice/end")
async def voice_end(body: VoiceEndRequest, _: None = Depends(require_token)) -> dict:
VOICE_SESSIONS.end(body.session_id)
return {"ok": True}
```
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (2 tests).
- [ ] **Step 5 : Test live contre ailiance (manuel, hors CI)**
Monter le gateway local (venv + `ZACUS_HUB_TOKEN`), puis :
```bash
curl -s -XPOST http://127.0.0.1:8400/v1/voice/turn \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"session_id":"t1","number":"17","kind":"greeting"}' -o /tmp/greet.wav -D -
file /tmp/greet.wav # RIFF WAVE 16000 Hz
```
Expected : 200, header `X-Zacus-Said`, WAV 16k jouable.
- [ ] **Step 6 : Commit**
```bash
git add tools/zacus-gateway/main.py tests/gateway/test_voice_turn.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): POST /v1/voice/turn (greeting path) + /v1/voice/end"
```
### Task 2.5 : Firmware — CONNECT/GREET appelle `/v1/voice/turn` et joue l'accueil
**Files:**
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECT/GREET)
- Create: `ESP32_ZACUS/plip_voice/main/turn_client.h` / `turn_client.c` (POST /v1/voice/turn, reçoit WAV → SPIFFS → play)
- Modify: `Kconfig.projbuild` (URL gateway + token)
- [ ] **Step 1 : Kconfig gateway**
```
config PLIP_GATEWAY_URL
string "Zacus gateway base URL"
default "http://192.168.0.188:8400" # ajuster à l'hôte gateway joignable du PLIP
config PLIP_GATEWAY_TOKEN
string "Zacus gateway bearer token"
default ""
```
- [ ] **Step 2 : `turn_client.c`** — POST greeting, écrit le WAV reçu dans `/spiffs/turn.wav`, retourne le chemin.
Calque `hook_client.c` (esp_http_client POST) mais : body JSON `{"session_id","number","kind":"greeting"}`, header `Authorization: Bearer <token>`, et **lecture de la réponse binaire** (WAV) en streaming → `fopen("/spiffs/turn.wav","wb")` + `esp_http_client_read`. Signature :
```c
// turn_client.h
#pragma once
#include <stdbool.h>
// POST /v1/voice/turn (greeting). Écrit le WAV dans out_path (/spiffs/...). true si OK.
bool turn_client_greeting(const char *session_id, const char *number, const char *out_path);
```
- [ ] **Step 3 : État CONNECT/GREET dans conversation.c**
Après RINGBACK (~2 s), au lieu de rester : générer un `session_id` (ex. `snprintf("%lld", esp_timer_get_time())`), appeler `turn_client_greeting(sid, dialer_current(), "/spiffs/turn.wav")`, puis `tones_stop(); audio_play_async("/spiffs/turn.wav");` → état CONNECTED. (Stage 3 ajoutera la boucle LISTEN.)
```c
case ST_RINGBACK:
if (ringback_elapsed_ms() > 2000){
tones_stop();
snprintf(s_sid, sizeof(s_sid), "%lld", esp_timer_get_time());
if (turn_client_greeting(s_sid, dialer_current(), "/spiffs/turn.wav"))
audio_play_async("/spiffs/turn.wav");
s_st = ST_CONNECTED;
}
break;
case ST_CONNECTED: /* Stage 3: LISTEN → turn(reply) → play, en boucle */ break;
```
- [ ] **Step 4 : Build + flash + test bout-en-bout (sens unique)**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
Test : gateway prod/local joignable depuis le PLIP. Décroché → tonalité → `curl "http://192.168.0.138/debug/dial?number=17"` → série : route known → ringback → POST turn → **le combiné dit « Police secours, je vous écoute »**. (combiné décroché pour entendre, mute-on-hook oblige.)
Expected : le PNJ parle.
- [ ] **Step 5 : Commit + PR submodule + bump** (branche `feat/plip-phone-stage2-fw`).
### Task 2.6 : Lander Stage 2 (gateway + déploiement)
- [ ] **Step 1 : PR gateway** (route + annuaire + sessions) → merge.
- [ ] **Step 2 : Déployer le gateway** (scp `main.py` + `phone_directory.yaml``/home/electron/zacus-hub/...`, restart `zacus-hub-gateway`, `.bak` avant) — **nécessite autorisation explicite de déploiement prod**.
- [ ] **Step 3 : Vérif prod** : `POST /v1/voice/turn {number:17,kind:greeting}` → WAV.
---
## STAGE 3 — Micro + whisper → conversation deux sens
### Task 3.1 : Vérifier la capture micro (dépendance hardware)
**Files:** aucun changement code tant que non diagnostiqué.
- [ ] **Step 1 : Test capture combiné DÉCROCHÉ**
Combiné branché + **décroché** (SLIC connecte l'audio). Parler dans le combiné pendant :
```bash
curl -s -XPOST "http://192.168.0.138/voice/capture?max_ms=4000" -o /tmp/cap.wav
python3 - <<'PY'
import wave,struct
w=wave.open('/tmp/cap.wav'); n=w.getnframes(); d=w.readframes(n)
s=struct.unpack('<%dh'%(len(d)//2), d)
rms=(sum(x*x for x in s)/max(1,len(s)))**0.5
print("samples",len(s),"rms",round(rms,1))
PY
```
Expected : RMS nettement > 0 (voix captée). **Si RMS ≈ 0** : passer au Step 2.
- [ ] **Step 2 : Si zéros — basculer l'entrée ES8388 (LIN2/RIN2)**
Dans `es8388.c`, la config ADC sélectionne l'entrée (LINPUT1/RINPUT1). Si le SLIC est câblé sur l'entrée 2, changer la sélection sur LINPUT2/RINPUT2 (registre ADCCONTROL2/3 selon datasheet ES8388). Rebuild + flash + re-tester le Step 1. Documenter le pin réel. (Diagnostic possible via `GET /debug/regs` ajouté précédemment.)
- [ ] **Step 3 : Commit** (si changement entrée) : `git -C ESP32_ZACUS ... commit -am "fix(plip): ES8388 ADC input selection matches SLIC audio wiring"`.
### Task 3.2 : Endpoint STT vivant (whisper)
**Files:**
- Modify: `tools/zacus-gateway/main.py` (Settings `whisper_url` + `_transcribe`)
- Test: `tests/gateway/test_transcribe.py`
- [ ] **Step 1 : Relancer whisper** : soit la voice-bridge sur studio (`/voice/transcribe` → whisper.cpp :8300), soit whisper.cpp seul. Vérifier `curl -F file=@/tmp/cap.wav <whisper_url>` → JSON `{text}`. Noter l'URL joignable depuis le gateway host. (Infra — hors code ; documenter la commande de démarrage.)
- [ ] **Step 2 : Test `_transcribe` (mock)** `tests/gateway/test_transcribe.py` :
```python
import sys, asyncio
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
class FakeResp:
status_code = 200
def json(self): return {"text":"bonjour professeur"}
class FakeHTTP:
async def post(self,*a,**k): return FakeResp()
def test_transcribe_returns_text():
out = asyncio.run(main._transcribe(FakeHTTP(), b"RIFF....", "x.wav"))
assert out == "bonjour professeur"
```
- [ ] **Step 3 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → FAIL.
- [ ] **Step 4 : Implémenter `_transcribe`** (Settings `whisper_url: str = "http://studio:8200"`, endpoint `/voice/transcribe` multipart) :
```python
async def _transcribe(http, wav_bytes: bytes, filename: str = "speech.wav") -> str:
files = {"file": (filename, wav_bytes, "audio/wav")}
resp = await http.post(f"{settings.whisper_url.rstrip('/')}/voice/transcribe",
files=files, timeout=60.0)
if resp.status_code != 200:
raise RuntimeError(f"stt HTTP {resp.status_code}")
return (resp.json().get("text") or "").strip()
```
- [ ] **Step 5 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → PASS.
- [ ] **Step 6 : Commit** : `git add ... && git commit -m "feat(gateway): whisper STT helper (_transcribe)"`.
### Task 3.3 : Voie `reply` de `/v1/voice/turn` (audio → STT → chat → TTS)
**Files:**
- Modify: `tools/zacus-gateway/main.py` (la route accepte le corps audio en `kind=reply`)
- Test: `tests/gateway/test_voice_turn.py` (ajouter un cas reply)
- [ ] **Step 1 : Test reply (mock _transcribe + _chat_reply + _voice_tts_16k)**
Ajouter à `test_voice_turn.py` un test qui POST `kind=reply` avec un corps WAV (multipart ou raw body) et `number`/`session_id` en query, mocke `_transcribe`→"allo", `_chat_reply`→"oui ?", `_voice_tts_16k`→WAV ; assert 200, header `X-Zacus-Heard: allo`.
- [ ] **Step 2 : Lancer → échoue** (la route ignore encore l'audio). Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → le nouveau test FAIL.
- [ ] **Step 3 : Adapter la route** : pour `kind=reply`, lire le corps audio (le PLIP enverra le WAV en body brut + `number`/`session_id`/`kind` en query params, car le body est l'audio). Appeler `_transcribe(http, audio_bytes)``heard`, l'ajouter à l'historique, puis chat + TTS comme avant.
```python
@app.post("/v1/voice/turn")
async def voice_turn(request: Request, session_id: str, number: str,
kind: str = "reply", _: None = Depends(require_token)):
entry = PHONE_DIRECTORY.get(number)
if not entry: raise HTTPException(404, f"numéro {number} non attribué")
http = request.app.state.http; persona = entry.get("persona",""); voice = entry.get("voice")
heard = ""
if kind == "greeting":
said = entry.get("greeting") or await _chat_reply(http, persona, [])
else:
audio = await request.body()
try:
heard = await _transcribe(http, audio)
except Exception:
heard = ""
VOICE_SESSIONS.append(session_id, "user", heard or "(inaudible)")
if not heard:
said = "Allô ? Je vous entends très mal, pouvez-vous répéter ?"
else:
said = await _chat_reply(http, persona, VOICE_SESSIONS.history(session_id))
VOICE_SESSIONS.append(session_id, "assistant", said)
wav16,_d,_t = await _voice_tts_16k(http, said, voice)
return Response(content=wav16, media_type="audio/wav",
headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]})
```
(Note : `session_id`/`number`/`kind` passent en query params puisque le body porte l'audio. Mettre à jour le test greeting en conséquence : query params au lieu de json.)
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (tous).
- [ ] **Step 5 : Commit** : `git commit -m "feat(gateway): /v1/voice/turn reply path (audio→STT→chat→TTS)"`.
### Task 3.4 : Firmware — boucle LISTEN → turn(reply) → SPEAK
**Files:**
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECTED → boucle)
- Modify: `ESP32_ZACUS/plip_voice/main/turn_client.c` (fonction reply : capture → POST WAV → reçoit WAV)
- [ ] **Step 1 : `turn_client_reply`** — capture le micro (réutilise l'API `audio_capture_begin/read_frame/end`), assemble un WAV en mémoire/SPIFFS, POST en body brut à `/v1/voice/turn?session_id=..&number=..&kind=reply`, écrit la réponse WAV dans `/spiffs/turn.wav`.
```c
// turn_client.h : bool turn_client_reply(const char *sid, const char *number, const char *out_path);
```
(Capture : ~8 s max, VAD comme `/voice/capture`. Upload : `esp_http_client` POST streaming. Réutiliser le code de capture de `net.c::handle_voice_capture`.)
- [ ] **Step 2 : Boucle dans conversation.c**
```c
case ST_CONNECTED:
// après la fin de lecture de l'accueil/réponse :
if (audio_idle()){
if (turn_client_reply(s_sid, dialer_current(), "/spiffs/turn.wav"))
audio_play_async("/spiffs/turn.wav");
// reste en CONNECTED → reboucle ; raccroché géré globalement
}
break;
```
(Ajouter `audio_idle()` si absent : true quand la queue est vide et aucune lecture en cours.)
- [ ] **Step 3 : Build + flash + test conversation deux sens** (combiné décroché, micro vérifié Task 3.1, whisper up) : décroché → numéro → accueil → parler → Zacus répond → reboucle → raccroché = fin.
- [ ] **Step 4 : Commit + PR submodule + bump.**
---
## STAGE 4 — 3615 Minitel (handshake + agent robotique)
### Task 4.1 : Sample handshake Minitel + lecture sur 3615
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/minitel_tone.h` (PCM embarqué, court) OU pousser un WAV sur SPIFFS
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (si `number=="3615"`, jouer le handshake avant GREET)
- [ ] **Step 1 : Obtenir le son handshake Minitel** : générer/approximer le handshake (séquence de tonalités modem) en WAV 16k court (~3 s), le pousser sur `/spiffs/minitel.wav` via `/game/file`, OU l'embarquer. (Sans fichier, approximer par une séquence de tones via `tones`/`audio_play_tone` : porteuse ~1300/2100 Hz alternées.)
- [ ] **Step 2 : Cas 3615 dans conversation.c** : à la connexion, si `dialer_current()=="3615"`, jouer `/spiffs/minitel.wav` (ou la séquence), PUIS enchaîner `turn_client_greeting` (l'annuaire a déjà la persona robotique + `minitel:true`). Le reste de la boucle est identique.
- [ ] **Step 3 : Build + flash + test** : composer 3615 → son modem Minitel → « Trois mille six cent quinze. Service en ligne… » → conversation robotique.
- [ ] **Step 4 : Commit + PR submodule + bump.**
### Task 4.2 : Test annuaire complet au banc
- [ ] **Step 1 :** Pour chaque numéro (12/15/17/18/3615/Zacus) : composer → accueil du bon PNJ (voix/persona). Numéro inconnu → occupé. Raccroché à tout moment → silence immédiat.
- [ ] **Step 2 :** Mode dégradé : whisper down → l'accueil joue quand même, reply → « je vous entends mal » (fallback). Documenter.
---
## Self-Review (couverture spec)
- **§2 Architecture (A, /v1/voice/turn unique)** → Tasks 2.4, 3.3 ✓
- **§3 Séquence (tonalité/dialing/routing/ringback/busy/mute)** → Tasks 1.1, 1.4 ✓
- **§4 Dialer (impulsions SHK + DTMF + debug)** → Tasks 1.2 (debug), 1.3 (impulsions) ✓ ; **DTMF Goertzel = non couvert explicitement** (voir note ci-dessous).
- **§5 Annuaire (12/3615/15/17/18 + Zacus)** → Task 2.1 ✓
- **§6 Contrat /v1/voice/turn (session_id/number/kind/audio, headers debug, /end)** → Tasks 2.4, 3.3 ✓
- **§7 Services IA (ailiance chat+TTS, whisper STT)** → Tasks 2.3, 3.2 ✓
- **§8 Dépendances (micro, whisper)** → Tasks 3.1, 3.2 ✓
- **§9 Mode dégradé** → Task 4.2 Step 2 ✓
- **§10 Erreurs (STT vide, réseau, raccroché, inconnu, cap)** → Tasks 2.4 (404 inconnu), 3.3 (fallback STT vide), 1.4 (raccroché/busy) ✓
- **§12 Build incrémental** → Stages 1-4 ✓
- **§13 Tests** → tests gateway pytest (2.x, 3.x) + tests banc firmware ✓
**Gap identifié — DTMF Goertzel** : le spec liste le DTMF comme 2ᵉ source de numérotation, mais le plan ne le détaille pas (seuls impulsions + debug sont couverts). **Décision** : le DTMF est repoussé en option post-Stage-1 (le cadran à impulsions + debug couvrent la cible « vintage à cadran » prioritaire). À ajouter comme Task 1.6 ultérieure si un téléphone à touches est utilisé : décodage Goertzel des 8 fréquences sur les frames de `audio_capture_read_frame`, derrière `CONFIG_PLIP_DIAL_DTMF`. Ce report est cohérent avec la cible (téléphone à cadran) confirmée au brainstorming.
@@ -0,0 +1,174 @@
# Freenove Media Kit — énigmes locales (P1 son, P3 QR) — Design Spec
> Statut : design validé en brainstorming (2026-06-10), à relire avant plan
> d'implémentation. Approche retenue : **A — composant `local_puzzles`**.
## Contexte & objectif
La Freenove **ESP32-S3 Media Kit v1.2** (module **N16R8** : 16 Mo flash,
8 Mo PSRAM octale, caméra **OV2640**, micro I2S MEMS, haut-parleur, SD,
batterie) exécute le firmware master `idf_zacus`. Aujourd'hui le master est un
orchestrateur headless (ESP-NOW hub, `game_endpoint`, `voice_pipeline`,
`npc_engine`, relay scénario), et les énigmes sont des **nœuds ESP32 dédiés**
(`puzzles/p1,p5,p6,p7`).
**Objectif** : faire de la Freenove un **master portable tout-en-un** qui, en
plus d'orchestrer, prend en charge **directement** deux énigmes via ses
capteurs intégrés :
- **P3 — QR Treasure** : scanner N QR codes dans le bon ordre (caméra OV2640).
- **P1 — Séquence sonore** : valider une mélodie reproduite par le joueur
(micro I2S).
Les énigmes **P5 morse / P6 NFC / P7 coffre**, ainsi que **BOX-3** et **PLIP**,
restent des nœuds ESP-NOW : aucune modification de leur firmware.
### Non-objectifs
- Pas de migration de P5/P6/P7 sur la Freenove (périphériques externes non
« média » ; hors scope de ce spec).
- Pas de suppression du protocole ESP-NOW ni du `game_endpoint` existant.
- Pas de refonte du `npc_engine` / `voice_pipeline` au-delà du strict
nécessaire pour partager le micro.
## Architecture — `idf_zacus/components/local_puzzles/`
Nouveau composant, trois unités à responsabilité unique + un point d'entrée
commun. Chaque unité est testable et remplaçable indépendamment.
### `mic_broker`
Possède le canal I2S micro (RX) et distribue les frames PCM16 16 kHz à **un
seul** consommateur actif, selon un mode explicite :
```
typedef enum { MIC_IDLE, MIC_NPC_LISTEN, MIC_P1_SOUND } mic_mode_t;
void mic_broker_set_mode(mic_mode_t mode);
// Un consommateur s'enregistre ; il ne reçoit des frames que dans son mode.
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx);
```
- Résout la **contention micro** par construction : NPC et P1 ne tournent
jamais en même temps.
- `voice_pipeline` est refactoré pour **tirer ses frames du broker**
(`MIC_NPC_LISTEN`) au lieu de posséder l'I2S directement. Changement minimal :
le broker encapsule l'init I2S existante du `voice_pipeline`.
- Le mode est piloté par l'état de scénario (voir Flux de données).
### `qr_puzzle`
- Init **`esp32-camera`** (composant `espressif/esp32-camera`) avec le pin-map
**Freenove ESP32-S3 Media Kit** (OV2640, voir Annexe pin-map).
- Décodage QR via **`quirc`** (bibliothèque C, intégrée en composant — **pas**
la lib Arduino `lib/ESP32QRCodeReader_vendor`, incompatible IDF).
- Boucle de scan active **uniquement** quand P3 est en cours ; accumule les
codes lus et valide la **séquence** contre l'ordre attendu (réutilise la
logique `correct_order` du bloc P3 de l'atelier).
- Caméra **dé-initialisée** hors P3 (`esp_camera_deinit`) — économie batterie
et libération PSRAM.
### `sound_puzzle`
- Consommateur `MIC_P1_SOUND` du broker.
- Détection d'attaque/hauteur (énergie + estimation de fréquence, fenêtres
glissantes) ; valide la **séquence de notes** reproduite contre la mélodie
attendue (logique reprise du nœud `puzzles/p1_sequence_sonore`).
- Tolérance/seuils paramétrables (difficulté).
### `local_puzzle_report()`
```
void local_puzzle_report(uint8_t puzzle_id, const uint8_t code_fragment[4]);
```
Point d'entrée commun : **synthétise un événement « puzzle résolu »** dans
`game_endpoint`, identique au chemin du handler `MSG_PUZZLE_SOLVED` des nœuds
ESP-NOW. Le master traite ainsi énigmes **locales et distantes de façon
uniforme** (assemblage du code, ligne NPC, LED, scoring).
## Flux de données
```
scénario/état de jeu
│ active P1 ou P3 (étape courante)
local_puzzles ──arme le module──► qr_puzzle / sound_puzzle
│ règle le mode micro (mic_broker_set_mode)
succès (séquence/mélodie correcte)
local_puzzle_report(puzzle_id, code_fragment)
game_endpoint ──► même aval que les énigmes ESP-NOW
(code assemblé, NPC, LED, score)
```
## Configuration & compatibilité
Chaque énigme est déclarée **`local` vs `remote`** (dans `boards.yaml` master
ou le scénario). Le **même firmware** fonctionne que P1/P3 soient locaux
(Freenove) ou en nœud ESP-NOW. Le nœud `puzzles/p1_sequence_sonore` devient
**optionnel** : repli si la caméra/micro local est indisponible.
## Gestion des erreurs & batterie
- **Échec init caméra** → P3 retombe sur le nœud ESP-NOW s'il est déclaré,
sinon log d'erreur et P3 inactif (le reste du jeu continue).
- **Contention micro** → impossible (mode exclusif du broker).
- **Batterie** → caméra dé-initialisée hors P3 ; micro en `MIC_IDLE` quand ni
NPC ni P1 actifs. Pas de capture inutile.
- **PSRAM** → 8 Mo octale (N16R8) : framebuffer caméra (QVGA/VGA suffisant pour
QR) + quirc large ; budget confortable.
## Tests
- **Logique pure (host)** : validateur de séquence QR (`correct_order`) et
validateur de mélodie (séquence de notes + tolérance) — tests unitaires sans
matériel.
- **Smoke hardware** : scanner un QR connu → `qr_puzzle` rapporte la bonne
valeur ; jouer un ton/mélodie → `sound_puzzle` valide ; vérifier que
`local_puzzle_report` déclenche le même aval qu'un `MSG_PUZZLE_SOLVED` ESP-NOW.
- **Régression** : NPC vocal toujours fonctionnel (le broker n'a pas cassé le
`voice_pipeline`).
## Découpage suggéré (pour le plan)
1. `mic_broker` + refacto `voice_pipeline` pour passer par le broker (NPC
inchangé fonctionnellement). **Indépendant, testable seul.**
2. `qr_puzzle` (caméra + quirc + validateur de séquence) + `local_puzzle_report`
branché sur `game_endpoint`. **Livrable visible : P3 sur la Freenove.**
3. `sound_puzzle` (détection mélodie via broker). **Livrable : P1 sur la
Freenove.**
4. Flag de config `local|remote` par énigme + repli ESP-NOW.
Chaque étape compile et est flashable indépendamment.
## Annexe — pin-map caméra Freenove ESP32-S3 (OV2640)
À confirmer sur le schéma **Media Kit v1.2** avant implémentation (valeurs
Freenove ESP32-S3-WROOM CAM standard) :
| Signal | GPIO | Signal | GPIO |
|---|---|---|---|
| PWDN | -1 | HREF | 7 |
| RESET | -1 | PCLK | 13 |
| XCLK | 15 | D7 | 16 |
| SIOD (SDA) | 4 | D6 | 17 |
| SIOC (SCL) | 5 | D5 | 18 |
| VSYNC | 6 | D4 | 12 |
| D3 | 10 | D2 | 8 |
| D1 | 9 | D0 | 11 |
**Micro I2S MEMS** : pins à confirmer sur le schéma Media Kit v1.2 (le
`voice_pipeline` actuel connaît déjà la config micro du master — la réutiliser
via le broker plutôt que la redéfinir).
## Inconnues résiduelles (à lever au début du plan)
1. Pins exactes du **micro I2S** sur le Media Kit v1.2 (récupérer depuis la
config `voice_pipeline` existante).
2. Confirmer **OV2640** (vs OV5640) et la résolution minimale fiable pour le
décodage QR à la distance de jeu.
3. Disponibilité du composant **`quirc`** en registre IDF, sinon vendoring.
@@ -0,0 +1,78 @@
# ESP-NOW commandes & événements — design v1 (2026-06-11)
## Problème
Les 4 blocs Blockly ESP-NOW compilent vers des actions IR
(`espnow_register_peer`, `espnow_send`, `espnow_broadcast`, `espnow_wait`)
que **personne n'exécute** : le gateway ne sait que relayer des IR complets
(`/game/scenario/relay`), et le firmware master ignore les actions des steps.
La pile radio existe pourtant (`ESP32_ZACUS/lib/scenario_mesh/`, partagée
master/annexes) mais ne transporte que des chunks d'IR.
## Décisions de cadrage (validées)
1. **Rôle** : commandes ET événements — le scénario commande les annexes et
attend leurs événements.
2. **Orchestration** : **master autonome** — le firmware Freenove
(`idf_zacus`) exécute lui-même les actions espnow des steps ; pas de
dépendance au gateway pendant la partie.
3. **Périmètre** : bout-en-bout avec la BOX-3 (`box3_voice`) comme annexe.
4. **Sémantique `espnow_wait`** : action **bloquante** (événement ou timeout,
puis le déroulé continue ; timeout → log, pas d'échec de step).
5. **Côté annexe** : handler **ouvert** — la commande est une chaîne opaque
passée à un callback enregistrable ; l'annexe répond l'événement de son
choix. Démo livrée : `ping``pong`, branchement optionnel `stim:*`.
## Architecture (approche A retenue)
### 1. Protocole — `lib/scenario_mesh` (composant partagé)
- Les frames gagnent un **type** : `IR_CHUNK` (existant), `CMD`, `EVT`.
- Payload CMD/EVT : chaîne UTF-8 opaque ≤ 200 octets.
- API additive :
- `esp_err_t scenario_mesh_send_text(const uint8_t mac[6], uint8_t kind, const char *text);`
- `void scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb);`
(callback : kind, MAC source, texte ; dispatché hors contexte WiFi via la
queue/tâche existante).
- Pas d'ack applicatif en v1 : statut d'envoi radio natif + confirmation
logique par `wait`.
### 2. Master — `idf_zacus`
- À l'application d'un step, exécution séquentielle des actions `espnow_*`
(les autres kinds restent ignorés) :
- `espnow_register_peer {alias, mac}``scenario_mesh_register_peer`.
- `espnow_send {peer, command}` → résolution alias → frame CMD.
- `espnow_broadcast {command}` → frame CMD vers FF:FF:FF:FF:FF:FF.
- `espnow_wait {command, timeout_ms}` → sémaphore + timeout, débloqué par
un EVT dont le texte égale `command` ; timeout → `ESP_LOGW` et on
continue.
- Endpoint debug : `POST /game/espnow/cmd {peer|broadcast, command}` pour
tester le tuyau sans scénario.
### 3. Annexe — `box3_voice`
- Enregistre le callback texte : sur CMD reçue, passe la chaîne au handler
ouvert du firmware.
- Handler de démo : `ping` → répond EVT `pong` au MAC source ; préfixe
`stim:` → déclenche le générateur de stimulus existant (`melody`, `qr`)
puis répond `done:<cmd>` (automatisera les tests physiques P1/P3).
### 4. Tests
- Export Blockly→IR : déjà couvert (`test_blocks_export_espnow.py`).
- Master seul : `POST /game/espnow/cmd` vers un peer absent → statut radio
loggé ; step avec `espnow_wait` sans annexe → timeout propre.
- Bout-en-bout 2 cartes : step `espnow_send box3 "ping"` +
`espnow_wait "pong" 10s` → logs série des deux côtés, wait débloqué.
## Hors scope v1
Retries/ack applicatif, chiffrement, événements→transitions IR, file
d'événements persistante, exécution des autres kinds d'actions par le master.
## Découpage
1. **Commit 1** : protocole CMD/EVT + exécuteur master + endpoint debug,
testé sur la Freenove seule (timeout, statuts radio).
2. **Commit 2** : handler BOX-3 (`ping`/`pong`, `stim:*`) + test 2 cartes.
@@ -0,0 +1,86 @@
# Refonte UI Atelier — « Console du Professeur Zacus »
**Date** : 2026-06-13
**Statut** : design approuvé (split vertical)
**Périmètre** : `frontend-v3/apps/atelier`
## 1. Intention
Refondre complètement l'UI de l'atelier d'authoring Zacus :
- **retirer** toute la simulation 3D (R3F/three) ;
- adopter un **langage visuel néo-rétro « labo du professeur »** (palette Workbench du firmware) ;
- **conserver Blockly** comme cœur de l'édition, refondre le layout autour ;
- remplacer la colonne 3D par un **Banc d'essai** : aperçu écran device fidèle **+** vue flux du scénario (split vertical, les deux visibles).
## 2. Layout cible
```
┌───────────────────────────────────────────────┐
│ ▦ ZACUS · ATELIER [Menu][Config][Déployer][✦Assets] ← barre labo (Orbitron)
├──────────────────────────┬────────────────────┤
│ Éditeur Blockly │ BANC D'ESSAI │
│ (thème Workbench) 55% │ ┌── Écran device ──┐ │ aperçu LCD ST7796 fidèle
│ │ └──────────────────┘ │ de l'étape sélectionnée
│ │ ┌── Flux scénario ─┐ │ graphe d'étapes/pivots
│ │ └──────────────────┘ │ clic nœud → écran + bloc
├──────────────────────────┴────────────────────┤
│ Console validation (schema/compile) · 25% │
└───────────────────────────────────────────────┘
```
- Colonne droite = `WorkbenchPane`, **split vertical** : `DeviceScreen` (haut) + `FlowGraph` (bas), via `react-resizable-panels` (déjà utilisé).
- `⌘B` : bascule l'affichage du Banc d'essai (remplace l'ancien toggle 3D).
## 3. Langage visuel (Workbench)
Repris 1:1 de `ESP32_ZACUS/.../display_ui.cpp` :
- Palette : fond bleu profond `#0055AA` → quasi-noir en surfaces ; accent `#FF8800` (orange Workbench) ; valeurs `#FFFFFF` ; labels `#AAAAAA`.
- Fonts : **Orbitron** (titres, chrome) + **IBM Plex Mono** (données, labels). Chargées localement (pas de CDN bloquant) avec fallbacks.
- Chrome : barre-titre façon device, cadres de panneaux étiquetés, LEDs de statut (gateway / cartes), légère texture grille/scanline.
- **Blockly** reçoit un thème custom assorti (API `Blockly.Theme`).
## 4. Composants
### Supprimés
- `components/StagePane.tsx`
- `scene/*` : NpcBubble, PuzzleStation, PuzzleStations, Room, RoomScene, RtcPhone
- `puzzles/*` : P1Sound, P5Morse, P6Symbols, P7Coffre
- `modes/*` : DemoMode, SandboxMode, TestMode
- `stores/simStore.ts`
- deps `three`, `@types/three`, `@react-three/fiber`, `@react-three/drei`
- `manualChunks` three/r3f dans `vite.config.ts`
- Console : onglets de **modes** (liés à la sim) → la console ne garde que la validation.
### Nouveaux
- `components/WorkbenchPane.tsx` — split vertical (Écran / Flux).
- `components/DeviceScreen.tsx` — réutilise le rendu fidèle de `ScreenPreview` (extrait de `ScreenEditorPanel`/`lib/screenSpec.ts`), branché sur la `scene` de l'étape sélectionnée.
- `components/FlowGraph.tsx` — nœuds = steps du scénario compilé (IR), arêtes = transitions/pivots ; clic → sélection d'étape.
- `stores/selectionStore.ts` — état léger : `selectedStepId`.
### Re-skinnés
- `Layout.tsx`, `MenuBar.tsx`, panneaux flottants (`GameConfigPanel`, `DeployPanel`, `AssetsPanel`), `main.css`, `BlocklyWorkspace.tsx` (thème).
## 5. Flux de données
- `FlowGraph` lit les steps depuis le scénario courant. Source : l'IR de `runtimeStore` (déjà calculé via le live-diff) ; à défaut, parse l'export YAML courant.
- Sélection d'un nœud → `selectionStore.selectedStepId``DeviceScreen` affiche la `scene` de ce step (title/subtitle/symbol/effect) ; option : focaliser le bloc Blockly correspondant.
- `DeviceScreen` se met aussi à jour quand l'IR change (édition live).
## 6. Découpage d'implémentation (3 phases)
- **A — Retrait 3D** : supprimer sim/3D, corriger `Layout`/`ConsolePane`/`App`, retirer les deps + `manualChunks`, mettre à jour les hooks e2e (`window.__atelierStores` perd `sim`). Pure soustraction — dérisque, build doit rester vert.
- **B — Thème Workbench** : palette/fonts/chrome/`main.css` + thème Blockly + re-skin barre & panneaux.
- **C — Banc d'essai** : `WorkbenchPane` + `DeviceScreen` + `FlowGraph` + `selectionStore` + wiring.
## 7. Tests & risques
- Gate à chaque phase : `pnpm exec tsc --noEmit` + `pnpm exec vite build` verts.
- Specs e2e pilotant la sim (`__atelierStores.sim`, modes) : à nettoyer/retirer en phase A.
- Risque principal : `FlowGraph` interactif (le morceau le plus consistant) — commencer simple (liste ordonnée de nœuds cliquables + arêtes de transition), enrichir ensuite.
- `DeviceScreen` dépend de la présence d'une `scene` sur le step ; fallback = afficher step_id/code comme le firmware.
## 8. Hors périmètre
- Refonte de l'édition par blocs (Blockly conservé tel quel, juste thémé).
- Playtest texte / runtime pas-à-pas (envisagé mais non retenu pour cette refonte).
- `apps/dashboard` (cette refonte concerne `apps/atelier`).
@@ -0,0 +1,88 @@
# DSL Blockly honnête & couplé hardware — Design
**Date** : 2026-06-14
**Statut** : design approuvé (hybride : honnête + roadmap ; priorités binding carte / média SD / transitions réelles)
**Périmètre** : `frontend-v3/packages/shared/blockly`, `frontend-v3/apps/atelier`, `tools/zacus-gateway`, `tools/scenario`
## 1. Problème
Le DSL Blockly actuel (64 blocs, source unique `frontend-v3/packages/shared/blockly/zacus_blocks.mjs`) est ~70 % **fantôme** : la plupart des blocs sont validés par le gateway puis **ignorés par le firmware**. Le firmware Freenove master sait réellement faire :
- **Énigmes** : `qr` + `sound` uniquement (`puzzle_binding.h` enum `PB_NONE/PB_QR/PB_SOUND`) ;
- **Écran** : step-extra `scene` (title≤47 / subtitle≤63 / symbol≤15 / effect∈pulse|glitch|gyro|none) ;
- **Média** : WAV + MP3 par **chemin** (`media_manager`, `POST /game/media/play {path}`), résolu sur `/sdcard/...` ou `/littlefs/...` ;
- **Relais ESP-NOW** : IR chunké (`/game/scenario/relay {peers,ir}`) + canal texte libre ≤200 o (sans consommateur structuré) ;
- **Voix** : déléguée au voice-bridge :8200 ;
- **PAS de moteur de transitions on-device** : le firmware est piloté pas-à-pas par `POST /game/step {step_id}` ; la logique vit côté gateway/web.
Sans support firmware : servo, relais, buzzer, NFC, morse/radio/LED/keypad, cartes M5/BOX-3/PLIP, couche GM. Non exposées en blocs alors que réelles : **binding à une carte précise**, **chemin média SD réel**, **transitions `button/serial/audio_done/unlock/espnow`**, **branchement sur l'état des énigmes** (`puzzle_state`).
## 2. Intention
Rendre le DSL **honnête** (refléter ce que la carte fait) et **réellement couplé au hardware**, en gardant les blocs non encore supportés comme **roadmap balisée** (hybride). Priorités : binding carte, média SD réel, transitions réelles.
## 3. Architecture cible
### 3.1 Deux voies de blocs
- **✅ RÉELLE** (exécutée on-device) : `sceneStart/End/Goto/Branch`, `scene`, `puzzleQR`, `puzzleSound`, média réel, relais ESP-NOW, `npcSay`/`npcIntentMatch`, transitions réelles.
- **🔬 HARDWARE FUTUR** (validée, non exécutée) : servo, relais, buzzer, NFC, `puzzleLED/Radio/Morse/NFC/Coffre`, M5/BOX-3/PLIP, GM. Catégorie(s) Blockly distincte(s), couleur/badge « non câblé », et un avertissement non bloquant à la compilation (warning, pas erreur).
La distinction vit dans `zacus_blocks.mjs` : un set `ZACUS_REAL_KINDS` (firmware-backed) ; `ZACUS_TOOLBOX_CATEGORIES` réorganisé pour grouper réel vs futur. La toolbox de l'atelier (`apps/atelier/src/components/toolbox.ts`) est générée depuis ces catégories — donc se met à jour automatiquement.
### 3.2 Binding carte
- Cartes connues : `master`, `box3`, `plip`, `p7` (cf. `tools/zacus-gateway/boards.yaml`).
- **Champ `board`** (défaut `master`) sur les blocs énigme (`puzzleQR`, `puzzleSound`) et un bloc contexte **`hwOnBoard`** (« ▸ sur la carte X { … } », c-block) qui cible les actions/cues qu'il contient.
- Compilation : le `board` est porté sur le step (`step.board`) et/ou l'action (`action.board`). Le gateway route :
- énigme/scénario d'une carte ≠ master → `POST /game/scenario/relay {peers:[board], ir}` ;
- cue média → `POST /game/media/play` **sur l'IP/relais de la bonne carte**.
- `board` validé contre `boards.yaml` (gateway) ; carte inconnue → erreur de compile claire.
### 3.3 Média réel (SD)
- Bloc **`mediaPlay`** : `path` (chemin SD réel, ex. `zacus/<board>/<file>.wav`, piochable dans le pool stagé P4) + `board` (hérité de `hwOnBoard` si présent) + `loop?`.
- Remplace l'usage opaque de `hwSoundPlay`(`asset`). Compile → action `sound_play{path,board}`.
- Le gateway expose le routage : action `sound_play` d'un step joué → `POST http://<ip(board)>/game/media/play {path}` (résolution IP/relais via `boards.yaml`). Cohérent avec le staging P4 (`/sdcard/zacus/{board}/`).
### 3.4 Transitions réelles
- Nouveaux blocs « **Quand …** » (hat/stack attachés à un step) produisant une `RuntimeTransition` avec le vrai `event_type` :
- `onButton(button)``event_type: button`, `event_name: <button>` ;
- `onAudioDone()``audio_done` ;
- `onUnlock(source)``unlock` ;
- `onSerial(token)``serial` ;
- `onEspnow(command)``espnow` ;
- `onTimer(seconds)``timer` (`after_ms`) — existe déjà via `logicTimer`, à aligner ;
- `onPuzzleSolved(puzzleId)` → branchement sur `puzzle_state` (résolu) ; `onCodeAssembled(code)` → idem code assemblé.
- Chaque bloc « Quand » porte une cible `target` (step id). Compilation dans `blocks_to_runtime3.py` : émettre la transition avec `event_type`/`event_name`/`target_step_id` corrects (aujourd'hui seuls `action`/`timer` sortent). `runtime3_common.py::EVENT_TYPES` déjà = `{button,serial,timer,audio_done,unlock,espnow,action}` → pas d'extension du schéma, juste de l'émission.
### 3.5 Contrat executor annexe (roadmap, doc seulement)
- Spécifier un **schéma CMD ESP-NOW structuré** (JSON compact ≤200 o) remplaçant le texte libre : `{op, args}` (ex. `{"op":"play","args":{"path":"…"}}`, `{"op":"led","args":{"pattern":"…"}}`). Documente le contrat (`docs/` ou spec firmware) pour que les cartes annexes (M5/BOX-3/PLIP) le décodent **plus tard** ; aucun firmware annexe écrit ici.
## 4. Composants & fichiers
| Fichier | Changement |
|---|---|
| `frontend-v3/packages/shared/blockly/zacus_blocks.mjs` | `ZACUS_REAL_KINDS` ; champ `board` sur puzzles ; blocs `hwOnBoard`, `mediaPlay`, `onButton/onAudioDone/onUnlock/onSerial/onEspnow/onPuzzleSolved/onCodeAssembled` ; réorg `ZACUS_TOOLBOX_CATEGORIES` (réel vs futur) |
| `frontend-v3/apps/atelier/src/components/toolbox.ts` | (auto depuis catégories) — vérifier le rendu 2 voies |
| `frontend-v3/apps/atelier/src/lib/yaml-export.ts` | sérialiser les nouveaux params (`board`, `path`, event targets) |
| `tools/zacus-gateway/blocks_to_runtime3.py` | compiler `board`, `mediaPlay``sound_play{path,board}`, blocs `onX`→transitions `event_type` réelles |
| `tools/scenario/runtime3_common.py` | validation `board`∈boards, `sound_play.path`, warning « bloc hardware-futur » |
| `tools/zacus-gateway/main.py` | routage : `sound_play`/relais vers la bonne carte (IP/relais via `boards.yaml`) ; endpoint de « run d'un step » si nécessaire |
| `docs/…/esp-now-cmd-contract.md` | contrat CMD structuré (roadmap) |
## 5. Découpage d'implémentation (phases)
- **D1** — Réorg honnête : `ZACUS_REAL_KINDS` + catégories toolbox 2 voies + badge/warning compile « hardware futur ». Web + compile, risque nul (aucune sémantique cassée). Régénérer le catalogue hub (`tools/dev/sync_blockly_catalog.sh` si présent).
- **D2** — Binding carte : champ `board` + bloc `hwOnBoard` + compile (`step.board`/`action.board`) + validation `boards.yaml` + routage gateway relais.
- **D3** — Média réel SD : bloc `mediaPlay(path,board,loop)` + compile `sound_play{path,board}` + routage `/game/media/play` sur la bonne carte.
- **D4** — Transitions réelles : blocs `onX``RuntimeTransition` avec `event_type` réels + branchement `puzzle_state`.
- **D5** — Doc contrat executor annexe ESP-NOW.
## 6. Tests & risques
- **Pipeline** : `make content-checks` / `make runtime3-*`, `python3 -m unittest discover -s tests`, et un round-trip blocs→IR validé (compile + validators) à chaque phase.
- **Frontend** : `tsc --noEmit` + `vite build` verts (toolbox).
- **Parité TS↔PY** : `yaml-export.ts` (serialize) et `blocks_to_runtime3.py` (compile) doivent rester en miroir — tout nouveau param sérialisé côté TS doit être lu côté PY.
- **E2E hardware** : routage gateway→carte (relais, media/play) testable seulement avec les cartes — hardware-gated ; sinon stub/contrat.
- **Risque principal** : la double implémentation TS (sérialisation/preview) + Python (compile/validation) — un champ ajouté d'un seul côté = silencieusement perdu. Chaque phase ajoute le champ des DEUX côtés + un test de parité.
## 7. Hors périmètre
- Profils de groupe (TECH/NON_TECH/MIXED/BOTH) — écartés pour cette itération.
- Écriture de firmware annexe (M5/BOX-3/PLIP) — seulement le contrat documenté (D5).
- Implémentation firmware des énigmes manquantes (morse/NFC/…) — restent « hardware futur ».
- Le graphe spatial FlowGraph (déjà fait, branche séparée).
@@ -0,0 +1,158 @@
# PLIP — Téléphone conversationnel France Télécom — Design
**Date** : 2026-06-14
**Statut** : design validé, prêt pour plan d'implémentation
**Carte** : PLIP (téléphone rétro), `ESP32_ZACUS/plip_voice/`, ESP32-A1S Audio Kit + SLIC classe AG1171, codec ES8388, IP 192.168.0.138
## 1. Objectif
Faire du PLIP un vrai **téléphone France Télécom interactif** dans l'escape room « Mystère du Professeur Zacus » : le joueur décroche, entend la tonalité, **compose un numéro** sur le cadran, et tombe sur un PNJ vocal (Professeur Zacus ou un service d'époque) avec qui il **converse** (reconnaissance vocale → réponse IA en personnage). Raccroché = fin + silence.
## 2. Architecture — Approche retenue (A)
Le **firmware PLIP possède la machine à états** (la « boucle » téléphonique) ; la **gateway** expose **un endpoint stateless-par-tour** qui enchaîne STT → LLM → TTS. Le PLIP ne fait ni TLS, ni prompt, ni resample.
```
COMBINÉ PLIP (firmware = machine à états)
raccroché → décroché → TONALITÉ → numérotation → routage annuaire
→ ringback → accueil PNJ → [ ÉCOUTE(VAD) → /v1/voice/turn → JOUE ]* → raccroché→IDLE+mute
└─ HTTP simple (plain, LAN) ─────────────► ZACUS-GATEWAY /v1/voice/turn (stateless/tour)
stt(whisper) → chat(ailiance + persona + historique) → tts(ailiance) → resample 16k
│ │ │
whisper :8300 gateway.ailiance.fr /v1/chat/completions .../v1/audio/speech
(À RELANCER) ✅ Ministral & co ✅ tts-1
```
**Frontière** :
- **PLIP firmware** : machine à états, `session_id` au décroché, capture (réutilise `/voice/capture` interne), POST le WAV, joue la réponse, reboucle ; raccroché = abort + mute (réutilise le mute-on-hook existant). Aucun TLS / prompt / resample sur l'ESP32.
- **Gateway** : pipeline IA d'un tour + **historique court par `session_id`** (mémoire, TTL 10 min). Persona + voix + annuaire = côté serveur, éditables sans reflasher.
**Décision** : **un seul appel `/v1/voice/turn` par tour** (PLIP envoie le WAV capté, reçoit le WAV de réponse) plutôt que 3 appels stt/chat/tts séparés → firmware bien plus simple, le PLIP garde la maîtrise du séquencement (quand écouter, stop VAD, jouer, reboucler, abort).
## 3. Séquence téléphonique (machine à états)
```
IDLE (raccroché, PA coupé, muet)
└─ DÉCROCHÉ (SHK haut)
→ DIAL_TONE : tonalité 440 Hz continu (FR) en boucle, attend le 1er chiffre
→ DIALING : collecte les chiffres (coupe la tonalité au 1er), fin = numéro complet (match annuaire)
OU timeout inter-chiffre ~3 s
→ ROUTING : recherche du numéro dans l'annuaire
├─ numéro connu → RINGBACK (440 Hz 1,5 s/3,5 s) → CONNECT
│ → GREET (kind=greeting) → [ LISTEN(VAD) → /v1/voice/turn(kind=reply) → SPEAK ]*
└─ numéro inconnu → tonalité « non attribué »/occupé (440 Hz 0,5/0,5) ~5 s → IDLE
→ RACCROCHÉ (SHK bas) à tout instant → IDLE + mute (abort tour en cours)
```
### Tonalités France Télécom (générées localement, pas de fichier)
- **Tonalité (invitation à numéroter)** : 440 Hz continu.
- **Occupé / non attribué** : 440 Hz, 0,5 s on / 0,5 s off.
- **Retour d'appel (ringback)** : 440 Hz, 1,5 s on / 3,5 s off.
- **Handshake Minitel** (pour 3615) : sample audio embarqué (le « bip strident » de connexion).
La tonalité/ringback passe par l'ampli PA → **forcer le PA on dès le décroché** (cohérent avec le mute-on-hook : décroché ⇒ PA on).
## 4. Numérotation — module `dialer`
Abstraction qui émet des événements `digit(09)` + `dial_complete`, **3 sources** combinables (Kconfig) :
- **Impulsions (cadran)** — `CONFIG_PLIP_DIAL_PULSE` : comptage d'impulsions sur le **pin SHK** du SLIC (la boucle s'ouvre/ferme pendant la numérotation au cadran ; 1 impulsion=1 … 10=0), gap inter-chiffre. **Bench-testable** avec un téléphone à cadran vintage branché sur le SLIC.
- **DTMF (touches)** — `CONFIG_PLIP_DIAL_DTMF` : décodage **Goertzel** des 8 fréquences DTMF sur l'audio capté via le SLIC. Bench-testable (dépend du chemin micro).
- **Debug/banc** — toujours dispo : `POST /debug/dial?number=NNNN` (+ option bouton BOOT = 1 chiffre) pour CI/validation sans téléphone physique.
Le SLIC classe **AG1171** fournit : hook + impulsions sur **SHK** (le hook GPIO actuel = SHK), audio combiné via le codec ES8388, sonnerie via les pins `RM`/`F-R`. Le mute-on-hook existant mappe exactement (SHK bas = raccroché = muet).
## 5. Annuaire (`game/scenarios/phone_directory.yaml`)
Table côté gateway : `numéro → { persona, voice, minitel? }`. Numéros supportés (réf. France Télécom) :
| Numéro | Service | Comportement |
|---|---|---|
| **12** | Renseignements (l'annuaire) | opératrice/standard : oriente, indice « quel numéro appeler » |
| **3615** | Minitel (kiosque Télétel) | **handshake modem Minitel** en intro, puis agent vocal spécial « service Minitel » (persona datée/robotique), même boucle `/voice/turn` |
| **15** | SAMU | PNJ secours médical en personnage |
| **17** | Police secours | PNJ police |
| **18** | Sapeurs-pompiers | PNJ pompiers |
Le numéro de **Professeur Zacus** (persona principale) est une entrée d'annuaire supplémentaire, numéro configurable. Le contenu des personas (caractère, ton, voix, anti-triche) est rempli par l'auteur après implémentation ; l'annuaire est extensible (autres PNJ = ajout d'entrées, sans reflasher). Numéro absent de l'annuaire → tonalité « non attribué ».
## 6. Contrat `/v1/voice/turn`
`POST /v1/voice/turn` (sous token) :
| Entrée | Type | Rôle |
|---|---|---|
| `session_id` | string | généré par le PLIP au décroché ; clé de l'historique |
| `number` | string | le numéro composé → choix persona/voix dans l'annuaire |
| `kind` | `"greeting"` \| `"reply"` | `greeting` = ouverture (pas d'audio) ; `reply` = tour normal |
| `audio` (corps) | WAV 16k mono | la parole captée (absent si `kind=greeting`) |
**Sortie** : `200`, `Content-Type: audio/wav` = WAV 16k **prêt à jouer**. En-têtes debug : `X-Zacus-Heard` (transcription) + `X-Zacus-Said` (réponse texte).
`POST /v1/voice/end {session_id}` → purge l'historique (best-effort ; sinon TTL 10 min).
**Historique serveur** : `dict[session_id] → messages[]` (system persona du numéro + alternance user/assistant), borné aux ~8 derniers tours, purgé sur `/v1/voice/end` ou TTL.
### Flux d'un appel
```
1. numéro routé → POST /turn {session_id, number, kind:greeting}
→ serveur : chat(persona accueil) → TTS → WAV → PLIP joue l'accueil
2. PLIP capture le micro (VAD : démarre à la voix, stop au silence ~800 ms, cap ~8 s)
3. POST /turn {session_id, number, kind:reply, audio:WAV}
→ serveur : whisper(audio)→texte ; append historique ; chat(persona+historique)→réponse ; TTS→resample 16k → WAV
4. PLIP joue la réponse → retour étape 2
5. raccroché → abort + mute ; POST /v1/voice/end (best-effort)
```
## 7. Services IA (état constaté 2026-06-14)
- **LLM (NPC brain)** : `gateway.ailiance.fr /v1/chat/completions` ✅ (Ministral & autres modèles ailiance).
- **TTS** : `gateway.ailiance.fr /v1/audio/speech` ✅ (model `tts-1`, voix `nova`/`alloy` ; sortie WAV 22050 Hz → resample 16k côté gateway, réutilise `_wav_to_16k_mono`).
- **STT** : **absent d'ailiance** (`/v1/audio/transcriptions` → 404). À fournir par whisper.cpp `:8300` ou la voice-bridge `/voice/transcribe` (studio:8200, **actuellement down**).
## 8. Dépendances bloquantes (honnêtes)
1. **Micro = vrai audio** : la capture renvoie aujourd'hui des zéros. Hypothèse forte : le SLIC ne connecte le chemin audio du combiné que **décroché**, et/ou l'entrée ES8388 utilisée n'est pas celle câblée à la sortie audio du SLIC. **Vérif à coût quasi nul** : téléphone branché + décroché → `/voice/capture` → RMS ≠ 0. Sinon, basculer l'entrée ES8388 (LIN2/RIN2 vs LIN1/RIN1) selon le câblage SLIC.
2. **Endpoint whisper vivant** : relancer la voice-bridge sur studio OU whisper.cpp `:8300` OU exposer un endpoint STT sur la gateway.
## 9. Mode dégradé (sans micro / sans STT)
Le téléphone marche **en sens unique sans ces deux dépendances** : tonalité, numérotation, routage annuaire, ringback, **et chaque PNJ débite sa réplique d'accueil (TTS)**. La boucle d'écoute (LISTEN→STT) est sautée. → **un téléphone-annuaire France Télécom jouable est livrable AVANT le micro/whisper.**
## 10. Gestion d'erreur
- **STT vide/échec** → la gateway renvoie un WAV de relance (« Allô ? je vous entends mal, répétez ? », par persona) ; la boucle continue.
- **Erreur réseau/ailiance** → WAV de repli (« la ligne est mauvaise… »).
- **Raccroché en plein tour** → le PLIP abort le HTTP en cours (best-effort), stoppe l'audio, mute, IDLE.
- **Numéro inconnu** → tonalité « non attribué » → IDLE.
- **Taille réponse** capée ~7 s (240 KiB), cohérent avec `/v1/voice/say` et le `MAX_BODY` 256K du PLIP (upload/playback streaming).
## 11. Composants & fichiers
**Firmware PLIP** (`ESP32_ZACUS/plip_voice/main/`) :
- `dialer.c/.h` (neuf) : impulsions SHK + DTMF Goertzel + `/debug/dial` → événements digit/dial_complete.
- `tones.c/.h` (neuf) : tonalités FR + sample handshake Minitel.
- `conversation.c/.h` (neuf) : machine à états complète, `session_id`, capture→POST `/v1/voice/turn`→play, abort au raccroché.
- extension `phone.c` : les événements hook alimentent `conversation` ; SHK pulse → `dialer`.
- client HTTP : POST du WAV capté à la gateway, réception du WAV de réponse.
- réutilise : `/voice/capture` (mic+VAD), play streaming, mute-on-hook, ring.
**Gateway** (`tools/zacus-gateway/`) :
- `POST /v1/voice/turn`, `POST /v1/voice/end`.
- chargement `game/scenarios/phone_directory.yaml` (annuaire → persona/voix).
- store de sessions en mémoire (TTL).
- réutilise `_synthesise_wav(ailiance)` + `_wav_to_16k_mono`.
## 12. Étapes de build (incrémental, chaque étape livrable)
1. **Tonalités + dialer + machine à états** jusqu'à RINGBACK — testable au son, **zéro IA**.
2. **Gateway `/v1/voice/turn` voie greeting + annuaire** → composer un numéro = **entendre le PNJ parler** (sens unique, sans micro/whisper). **Livrable jouable.**
3. **Vérif micro (SLIC décroché) + whisper****conversation deux sens**.
4. **3615 Minitel** (handshake + agent robotique).
## 13. Tests
- **Gateway** (unit) : `/v1/voice/turn` avec WAV fixture → routage persona par numéro, accumulation historique, voie greeting, fallbacks d'erreur (ailiance mocké ou live) ; chargement annuaire.
- **Firmware** (banc, téléphone à cadran via SLIC) : décroché→tonalité ; composer 17→ringback→accueil police ; parler→réponse (gated micro) ; 3615→handshake Minitel→agent robotique ; numéro inconnu→occupé ; raccroché→silence. Impulsions via SHK ; DTMF Goertzel ; `/debug/dial` pour CI.
- **Dégradé** : STT down → accueil + fallback fonctionnent.
+1
View File
@@ -0,0 +1 @@
VITE_GATEWAY_BASE_URL=https://zacus.saillant.cc/api
+18 -20
View File
@@ -1,8 +1,10 @@
# Atelier
Scratch-like authoring studio: Blockly editor (left/center) + R3F 3D simulation stage (right) + validation console (bottom). Live-diff: edit a block → 500 ms debounce → stale badge → click Run → simulation reloads via `simStore.loadScenario(yaml)`.
Scratch-like authoring studio: Blockly editor (left/center) + **Workbench pane** (right) + validation console (bottom). Live-diff: edit a block → 500 ms debounce → stale badge → click Run → `runtimeStore.commitPendingIr()`.
Design doc: `docs/superpowers/specs/2026-05-01-v3-fusion-atelier-design.md`.
**Refonte UI « Workbench » en cours** (spec `docs/superpowers/specs/2026-06-13-atelier-refonte-workbench-design.md`, plan `docs/superpowers/plans/2026-06-13-atelier-refonte-workbench.md`). La simulation 3D (R3F/three, `StagePane`, `scene/`, `puzzles/`, `modes/`, `simStore`) a été **retirée** (phase A). La colonne droite est un placeholder « Banc d'essai » jusqu'à la phase C (= aperçu écran device fidèle + vue flux du scénario, split vertical). Phase B = thème néo-rétro Workbench (palette firmware, Orbitron/IBM Plex Mono).
Design doc historique : `docs/superpowers/specs/2026-05-01-v3-fusion-atelier-design.md`.
## Layout
@@ -13,20 +15,17 @@ src/
main.css # Shell + pane styles
vite-env.d.ts # Vite client types
components/
Layout.tsx # PanelGroup tree, ⌘B stage toggle
Layout.tsx # PanelGroup tree, ⌘B workbench toggle
EditorPane.tsx # Lazy(BlocklyWorkspace) + register blocks + validate on change
StagePane.tsx # Lazy(RoomScene + mode HUDs) + Run button
ConsolePane.tsx # Mode tabs + validation entries list
WorkbenchPane.tsx # (phase C) split vertical: DeviceScreen + FlowGraph — placeholder for now
ConsolePane.tsx # validation entries list (+ error/warn count)
BlocklyWorkspace.tsx # forwardRef wrapper around Blockly.inject
toolbox.ts # XML toolbox config
YamlPreview.tsx # Syntax-highlighted YAML rendering
{TextGen,AudioGen,ScreenEditor}Panel.tsx, AssetsPanel.tsx # ✦ Assets workspace (P2/P3a/P5)
blocks/
{puzzle,npc,flow}-blocks.ts # Block definitions + YAML generators
index.ts # registerAllBlocks (idempotent)
scene/ # R3F 3D scene (RoomScene, RtcPhone, PuzzleStations, NpcBubble)
puzzles/ # P1Sound, P5Morse, P6Symbols, P7Coffre (3D widgets)
modes/ # SandboxMode, DemoMode, TestMode HUDs
stores/ # editor / runtime / sim / validation (zustand)
index.ts # registerAllBlocks (idempotent) — block defs live in @zacus/shared/blockly
stores/ # editor / runtime / validation (zustand) — NO simStore
lib/
yaml-export.ts # workspace -> YAML
yaml-import.ts # YAML -> workspace
@@ -36,12 +35,11 @@ src/
## Patterns
- **Lazy chunks** via `React.lazy` + Suspense for both Blockly (~700 kB gzip) and R3F+three (~1 MB gzip). Initial shell stays under 80 kB. Vite `manualChunks` in `vite.config.ts` enforces the split.
- **Stores stay focused**: `editorStore` = blocklyJson string ; `runtimeStore` = pending/current IR + isStale ; `simStore` = mode + engine instance + npcMood ; `validationStore` = entries with `severity` + `source` (schema|compile|runtime|test).
- **Mode is sim concern**, not editor↔stage sync — lives in `simStore`. ConsolePane reads from there.
- **Run button** in `StagePane` is the only commit point: it calls `simStore.loadScenario(pendingIr)` then `runtimeStore.commitPendingIr()`.
- **Tailwind classes are not configured** — use inline styles or `main.css` classes (`atelier-pane`, `atelier-mode-tab`, `atelier-resizer--*`). Tailwind in legacy simulation HUDs was rewritten as inline styles during P4.
- **Dev-only window hooks**: `App.tsx` exposes stores on `window.__atelierStores`, `Layout.tsx` exposes `window.__atelierToggleStage`, `EditorPane.tsx` exposes `window.__atelierBlockly.getWorkspace()`. All gated by `import.meta.env.DEV`. E2E specs drive workflow through them instead of relying on real DnD.
- **Lazy chunk** via `React.lazy` + Suspense for Blockly (~700 kB gzip). Initial shell stays small. Vite `manualChunks` in `vite.config.ts` enforces the blockly split (the three/R3F chunk was removed with the 3D sim).
- **Stores stay focused**: `editorStore` = blocklyJson string ; `runtimeStore` = pending/current IR + isStale ; `validationStore` = entries with `severity` + `source` (schema|compile|runtime|test). (phase C adds `selectionStore` = selectedStepId.)
- **Live-diff commit**: edits flow through `useLiveDiff` (debounce → `setPendingIr`); committing pending→current IR is `runtimeStore.commitPendingIr()`.
- **Tailwind classes are not configured** — use inline styles or `main.css` classes (`atelier-pane`, `atelier-resizer--*`). Phase B introduces a Workbench theme module `src/theme/workbench.ts` (palette/fonts) — import `WB` from there rather than hardcoding colours.
- **Dev-only window hooks**: `App.tsx` exposes stores on `window.__atelierStores` (editor/runtime/validation), `Layout.tsx` exposes `window.__atelierToggleStage`, `EditorPane.tsx` exposes `window.__atelierBlockly.getWorkspace()`. All gated by `import.meta.env.DEV`. E2E specs drive workflow through them instead of relying on real DnD.
## Build
@@ -54,8 +52,8 @@ pnpm exec vite build # for dev / desktop bundle (root path)
## Anti-patterns
- Putting `mode` back into `runtimeStore` — it belongs to `simStore`.
- Calling `voiceWsConnect()` or any lwip-touching code at boot without a network-ready guard (matches the ESP32_ZACUS bug fix from this session).
- Reintroducing the 3D sim / `simStore` / R3F deps — the refonte removed them deliberately.
- Hardcoding Workbench colours — import `WB` from `src/theme/workbench.ts` (phase B+).
- Hardcoding `/atelier/` as base path — the dev server uses root, only the deployed prod build uses the prefix.
- Adding deps to root `frontend-v3/package.json` — atelier deps live here.
- Bypassing the Run button to mutate `simStore.engine` directly — breaks the live-diff contract.
- Mutating `runtimeStore.currentIr` outside the live-diff contract (`setPendingIr``commitPendingIr`).
+5 -4
View File
@@ -10,22 +10,23 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/orbitron": "^5.2.8",
"@xyflow/react": "^12.11.0",
"@zacus/scenario-engine": "workspace:*",
"@zacus/shared": "workspace:*",
"@zacus/ui": "workspace:*",
"blockly": "^12.5.1",
"js-yaml": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^2.1.7",
"three": "^0.171.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.184.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
+6 -16
View File
@@ -1,9 +1,9 @@
import { Layout } from './components/Layout.js';
import { GameConfigPanel } from './components/GameConfigPanel.js';
import { MenuBar } from './components/MenuBar.js';
import { useLiveDiff } from './lib/useLiveDiff.js';
import { useShareLink } from './lib/useShareLink.js';
import { useEditorStore } from './stores/editorStore.js';
import { useRuntimeStore } from './stores/runtimeStore.js';
import { useSimStore } from './stores/simStore.js';
import { useValidationStore } from './stores/validationStore.js';
// Dev-only test hooks — used by e2e/ specs to drive store state without
@@ -13,27 +13,17 @@ if (import.meta.env.DEV) {
(window as unknown as { __atelierStores?: unknown }).__atelierStores = {
editor: useEditorStore,
runtime: useRuntimeStore,
sim: useSimStore,
validation: useValidationStore,
};
}
export function App() {
useLiveDiff();
useShareLink();
return (
<>
<div className="atelier-root">
<MenuBar />
<Layout />
<div
style={{
position: 'fixed',
top: 12,
right: 12,
zIndex: 50,
pointerEvents: 'auto',
}}
>
<GameConfigPanel />
</div>
</>
</div>
);
}
@@ -1,87 +0,0 @@
import * as Blockly from 'blockly/core';
import { javascriptGenerator } from 'blockly/javascript';
// 2 flow block definitions: phase container and puzzle selector
export const FLOW_BLOCK_DEFS = [
{
type: 'phase_container',
message0: 'PHASE %1',
args0: [
{
type: 'field_dropdown',
name: 'PHASE_TYPE',
options: [
['INTRO', 'INTRO'],
['PROFILING', 'PROFILING'],
['ADAPTIVE', 'ADAPTIVE'],
['CLIMAX', 'CLIMAX'],
['OUTRO', 'OUTRO'],
],
},
],
message1: '%1',
args1: [{ type: 'input_statement', name: 'PUZZLES' }],
colour: '#2ECC71',
tooltip: 'Conteneur de phase du scénario',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_selector',
message0: 'Puzzle %1 | profil %2',
args0: [
{
type: 'field_dropdown',
name: 'PUZZLE_REF',
options: [
['P1 Séquence Sonore', 'P1_SON'],
['P2 Circuit LED', 'P2_CIRCUIT'],
['P3 QR Treasure', 'P3_QR'],
['P4 Radio', 'P4_RADIO'],
['P5 Morse', 'P5_MORSE'],
['P6 Symboles NFC', 'P6_SYMBOLES'],
['P7 Coffre Final', 'P7_COFFRE'],
],
},
{
type: 'field_dropdown',
name: 'PROFILE_FILTER',
options: [
['tous (BOTH)', 'BOTH'],
['tech', 'TECH'],
['non-tech', 'NON_TECH'],
],
},
],
colour: '#2ECC71',
tooltip: 'Référence à un puzzle dans une phase',
previousStatement: null,
nextStatement: null,
},
] as const;
export function registerFlowBlocks(): void {
for (const def of FLOW_BLOCK_DEFS) {
Blockly.Blocks[def.type] = {
init(this: Blockly.Block) {
this.jsonInit(def);
},
};
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerFlowGenerators(gen: any): void {
gen['phase_container'] = (block: Blockly.Block) => {
const phaseType = block.getFieldValue('PHASE_TYPE') as string;
const inner = javascriptGenerator.statementToCode(block, 'PUZZLES');
return `\n- type: ${phaseType}\n puzzles:${inner || '\n []'}`;
};
gen['puzzle_selector'] = (block: Blockly.Block) => {
const ref = block.getFieldValue('PUZZLE_REF') as string;
const filter = block.getFieldValue('PROFILE_FILTER') as string;
return `\n - puzzle_ref: ${ref}\n profile_filter: ${filter}`;
};
}
+6 -21
View File
@@ -1,30 +1,15 @@
import { javascriptGenerator } from 'blockly/javascript';
import { registerPuzzleBlocks, registerPuzzleGenerators } from './puzzle-blocks.js';
import { registerNpcBlocks, registerNpcGenerators } from './npc-blocks.js';
import { registerFlowBlocks, registerFlowGenerators } from './flow-blocks.js';
import * as Blockly from 'blockly/core';
import { registerZacusBlocks } from '@zacus/shared/blockly';
let registered = false;
/**
* Register all 12 Blockly block definitions and their YAML generators.
* Safe to call multiple times — only registers once.
* Register the shared Zacus block catalog (38 Runtime 3 scene blocks —
* single source of truth in @zacus/shared/blockly, also consumed by the
* zacus-hub native Studio). Safe to call multiple times.
*/
export function registerAllBlocks(): void {
if (registered) return;
registered = true;
// Register block shapes
registerPuzzleBlocks();
registerNpcBlocks();
registerFlowBlocks();
// Register YAML generators (use javascriptGenerator as the vehicle)
registerPuzzleGenerators(javascriptGenerator);
registerNpcGenerators(javascriptGenerator);
registerFlowGenerators(javascriptGenerator);
registerZacusBlocks(Blockly);
}
// Re-export individual registrars for targeted use
export { registerPuzzleBlocks, registerPuzzleGenerators } from './puzzle-blocks.js';
export { registerNpcBlocks, registerNpcGenerators } from './npc-blocks.js';
export { registerFlowBlocks, registerFlowGenerators } from './flow-blocks.js';
@@ -1,97 +0,0 @@
import * as Blockly from 'blockly/core';
// 3 NPC block definitions: profiling, duration, adaptive rule
export const NPC_BLOCK_DEFS = [
{
type: 'npc_profiling',
message0: 'NPC Profiling | seuil tech %1 s | seuil non-tech %2 s',
args0: [
{ type: 'field_number', name: 'TECH_THRESHOLD_S', value: 300, min: 60, max: 600 },
{ type: 'field_number', name: 'NONTECH_THRESHOLD_S', value: 480, min: 60, max: 900 },
],
colour: '#5B4FD6',
tooltip: 'Configure la logique de profilage du groupe par le NPC',
previousStatement: null,
nextStatement: null,
},
{
type: 'npc_duration',
message0: 'NPC Durée | cible %1 min | mode %2',
args0: [
{ type: 'field_number', name: 'TARGET_MINUTES', value: 60, min: 30, max: 90 },
{
type: 'field_dropdown',
name: 'MODE',
options: [
['30 min', '30'],
['45 min', '45'],
['60 min', '60'],
['90 min', '90'],
],
},
],
colour: '#5B4FD6',
tooltip: 'Configure la durée cible de la session',
previousStatement: null,
nextStatement: null,
},
{
type: 'npc_adaptive_rule',
message0: 'NPC Règle | si %1 → %2',
args0: [
{
type: 'field_dropdown',
name: 'CONDITION',
options: [
['trop rapide', 'fast'],
['trop lent', 'slow'],
],
},
{
type: 'field_dropdown',
name: 'ACTION',
options: [
['ajouter puzzle', 'add'],
['passer puzzle', 'skip'],
['donner indice', 'hint'],
],
},
],
colour: '#5B4FD6',
tooltip: 'Règle adaptative NPC (ajustement en cours de jeu)',
previousStatement: null,
nextStatement: null,
},
] as const;
export function registerNpcBlocks(): void {
for (const def of NPC_BLOCK_DEFS) {
Blockly.Blocks[def.type] = {
init(this: Blockly.Block) {
this.jsonInit(def);
},
};
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerNpcGenerators(gen: any): void {
gen['npc_profiling'] = (block: Blockly.Block) => {
const tech = block.getFieldValue('TECH_THRESHOLD_S') as string;
const nontech = block.getFieldValue('NONTECH_THRESHOLD_S') as string;
return `\n profiling:\n tech_threshold_s: ${tech}\n nontech_threshold_s: ${nontech}`;
};
gen['npc_duration'] = (block: Blockly.Block) => {
const target = block.getFieldValue('TARGET_MINUTES') as string;
const mode = block.getFieldValue('MODE') as string;
return `\n duration:\n target_minutes: ${target}\n mode: "${mode}"`;
};
gen['npc_adaptive_rule'] = (block: Blockly.Block) => {
const cond = block.getFieldValue('CONDITION') as string;
const action = block.getFieldValue('ACTION') as string;
return `\n - condition: ${cond}\n action: ${action}`;
};
}
@@ -1,186 +0,0 @@
import * as Blockly from 'blockly/core';
// 7 puzzle block definitions with JSON defs.
// YAML generators are in ../yaml/generators.ts
export const PUZZLE_BLOCK_DEFS = [
{
type: 'puzzle_sequence_sonore',
message0: 'P1 Séquence Sonore | notes %1 | difficulté %2 | code digits %3',
args0: [
{ type: 'field_input', name: 'MELODY_NOTES', text: 'do,re,mi,fa' },
{ type: 'field_number', name: 'DIFFICULTY', value: 1, min: 1, max: 5 },
{ type: 'field_input', name: 'CODE_DIGITS', text: '1,2' },
],
colour: '#FF6B35',
tooltip: 'P1: Séquence Sonore — joueur reproduit une mélodie',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_circuit_led',
message0: 'P2 Circuit LED | composants %1 | circuit valide %2 | code digit %3',
args0: [
{ type: 'field_input', name: 'COMPONENTS', text: 'resistor,led,capacitor' },
{ type: 'field_input', name: 'VALID_CIRCUIT', text: 'series_parallel' },
{ type: 'field_number', name: 'CODE_DIGIT', value: 3, min: 0, max: 9 },
],
colour: '#FF6B35',
tooltip: 'P2: Circuit LED — joueur câble un circuit électronique',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_qr_treasure',
message0: 'P3 QR Treasure | QR codes %1 | ordre correct %2 | code digit %3',
args0: [
{ type: 'field_input', name: 'QR_CODES', text: 'qr1,qr2,qr3,qr4,qr5,qr6' },
{ type: 'field_input', name: 'CORRECT_ORDER', text: '3,1,4,1,5,9' },
{ type: 'field_number', name: 'CODE_DIGIT', value: 5, min: 0, max: 9 },
],
colour: '#FF6B35',
tooltip: 'P3: QR Treasure — scanner 6 QR codes dans le bon ordre',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_radio',
message0: 'P4 Radio | fréquence cible %1 | min %2 | max %3 | code digit %4',
args0: [
{ type: 'field_number', name: 'TARGET_FREQ', value: 93.5, min: 88, max: 108 },
{ type: 'field_number', name: 'RANGE_MIN', value: 93.0 },
{ type: 'field_number', name: 'RANGE_MAX', value: 94.0 },
{ type: 'field_number', name: 'CODE_DIGIT', value: 7, min: 0, max: 9 },
],
colour: '#FF6B35',
tooltip: 'P4: Fréquence Radio — syntoniser une fréquence précise',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_morse',
message0: 'P5 Morse | message %1 | mode %2 | code digits %3',
args0: [
{ type: 'field_input', name: 'MESSAGE', text: 'SOS' },
{
type: 'field_dropdown',
name: 'MODE',
options: [
['tech (clé télégraphe)', 'tech'],
['light (lampe)', 'light'],
],
},
{ type: 'field_input', name: 'CODE_DIGITS', text: '4,2' },
],
colour: '#FF6B35',
tooltip: 'P5: Code Morse — transmettre un message en morse',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_symboles_nfc',
message0: 'P6 Symboles NFC | symboles %1 | ordre correct %2 | code digits %3',
args0: [
{
type: 'field_input',
name: 'SYMBOLS',
text: 'alpha,beta,gamma,delta,sigma,omega,phi,psi,chi,rho,mu,nu',
},
{ type: 'field_input', name: 'CORRECT_ORDER', text: '1,4,7,2' },
{ type: 'field_input', name: 'CODE_DIGITS', text: '6,1' },
],
colour: '#FF6B35',
tooltip: 'P6: Symboles Alchimiques NFC — placer des tuiles dans le bon ordre',
previousStatement: null,
nextStatement: null,
},
{
type: 'puzzle_coffre_final',
message0: 'P7 Coffre Final | longueur code %1 | max tentatives %2',
args0: [
{ type: 'field_number', name: 'CODE_LENGTH', value: 8, min: 4, max: 12 },
{ type: 'field_number', name: 'MAX_ATTEMPTS', value: 3, min: 1, max: 10 },
],
colour: '#FF6B35',
tooltip: 'P7: Coffre Final — entrer le code assemblé depuis tous les puzzles',
previousStatement: null,
nextStatement: null,
},
] as const;
export function registerPuzzleBlocks(): void {
for (const def of PUZZLE_BLOCK_DEFS) {
Blockly.Blocks[def.type] = {
init(this: Blockly.Block) {
this.jsonInit(def);
},
};
}
}
// YAML generators for puzzle blocks
export function registerPuzzleGenerators(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gen: any,
): void {
gen['puzzle_sequence_sonore'] = (block: Blockly.Block) => {
const notes = block.getFieldValue('MELODY_NOTES') as string;
const diff = block.getFieldValue('DIFFICULTY') as string;
const digits = block.getFieldValue('CODE_DIGITS') as string;
return `\n - id: P1_SON\n name: "Séquence Sonore"\n melody_notes: [${notes
.split(',')
.map((n) => `"${n.trim()}"`)
.join(', ')}]\n difficulty: ${diff}\n code_digits: [${digits}]`;
};
gen['puzzle_circuit_led'] = (block: Blockly.Block) => {
const components = block.getFieldValue('COMPONENTS') as string;
const circuit = block.getFieldValue('VALID_CIRCUIT') as string;
const digit = block.getFieldValue('CODE_DIGIT') as string;
return `\n - id: P2_CIRCUIT\n name: "Circuit LED"\n components: [${components
.split(',')
.map((c) => `"${c.trim()}"`)
.join(', ')}]\n valid_circuit: "${circuit}"\n code_digit: ${digit}`;
};
gen['puzzle_qr_treasure'] = (block: Blockly.Block) => {
const qrs = block.getFieldValue('QR_CODES') as string;
const order = block.getFieldValue('CORRECT_ORDER') as string;
const digit = block.getFieldValue('CODE_DIGIT') as string;
return `\n - id: P3_QR\n name: "QR Treasure"\n qr_codes: [${qrs
.split(',')
.map((q) => `"${q.trim()}"`)
.join(', ')}]\n correct_order: [${order}]\n code_digit: ${digit}`;
};
gen['puzzle_radio'] = (block: Blockly.Block) => {
const freq = block.getFieldValue('TARGET_FREQ') as string;
const min = block.getFieldValue('RANGE_MIN') as string;
const max = block.getFieldValue('RANGE_MAX') as string;
const digit = block.getFieldValue('CODE_DIGIT') as string;
return `\n - id: P4_RADIO\n name: "Fréquence Radio"\n target_freq: ${freq}\n range_min: ${min}\n range_max: ${max}\n code_digit: ${digit}`;
};
gen['puzzle_morse'] = (block: Blockly.Block) => {
const msg = block.getFieldValue('MESSAGE') as string;
const mode = block.getFieldValue('MODE') as string;
const digits = block.getFieldValue('CODE_DIGITS') as string;
return `\n - id: P5_MORSE\n name: "Code Morse"\n message: "${msg}"\n mode: ${mode}\n code_digits: [${digits}]`;
};
gen['puzzle_symboles_nfc'] = (block: Blockly.Block) => {
const symbols = block.getFieldValue('SYMBOLS') as string;
const order = block.getFieldValue('CORRECT_ORDER') as string;
const digits = block.getFieldValue('CODE_DIGITS') as string;
return `\n - id: P6_SYMBOLES\n name: "Symboles Alchimiques"\n symbols: [${symbols
.split(',')
.map((s) => `"${s.trim()}"`)
.join(', ')}]\n correct_order: [${order}]\n code_digits: [${digits}]`;
};
gen['puzzle_coffre_final'] = (block: Blockly.Block) => {
const len = block.getFieldValue('CODE_LENGTH') as string;
const attempts = block.getFieldValue('MAX_ATTEMPTS') as string;
return `\n - id: P7_COFFRE\n name: "Coffre Final"\n code_length: ${len}\n max_attempts: ${attempts}`;
};
}
@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react';
import { TextGenBody } from './TextGenPanel.js';
import { AudioGenBody } from './AudioGenPanel.js';
import { ScreenEditorBody } from './ScreenEditorPanel.js';
import {
fetchBoards,
stageAssets,
armStep,
playMedia,
type StageBoard,
type StageResult,
type ArmStepResult,
type PlayMediaResult,
} from '../lib/stage.js';
/**
* P5 — unified "Assets" workspace. Folds the per-type authoring bodies (text /
* voice / screen) into one tabbed panel and adds a Deploy tab that stages the
* generated audio onto a board's SD via the gateway (POST /v1/assets/stage,
* P4). Replaces the three separate floating panels.
*/
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
type Tab = 'text' | 'voice' | 'screen' | 'deploy';
const TABS: { id: Tab; label: string }[] = [
{ id: 'text', label: 'Texte' },
{ id: 'voice', label: 'Voix' },
{ id: 'screen', label: 'Écran' },
{ id: 'deploy', label: 'Déployer' },
];
function DeployTab() {
const [boards, setBoards] = useState<StageBoard[]>([]);
const [board, setBoard] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<StageResult | null>(null);
const [error, setError] = useState<string | null>(null);
// « Tester sur la carte » — arm a step (D2) / play a media cue (D3) on the selected board.
const [stepId, setStepId] = useState('STEP_intro');
const [mediaPath, setMediaPath] = useState('');
const [testBusy, setTestBusy] = useState(false);
const [testResult, setTestResult] = useState<ArmStepResult | PlayMediaResult | null>(null);
const [testError, setTestError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
fetchBoards()
.then((b) => { if (alive) { setBoards(b); setBoard((cur) => cur || b[0]?.name || ''); } })
.catch((e) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
return () => { alive = false; };
}, []);
const onStage = async () => {
if (!board) return;
setBusy(true);
setError(null);
setResult(null);
try {
setResult(await stageAssets(board, { strategy: 'auto' }));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const runTest = async (fn: () => Promise<ArmStepResult | PlayMediaResult>) => {
if (!board) return;
setTestBusy(true);
setTestError(null);
setTestResult(null);
try {
setTestResult(await fn());
} catch (err) {
setTestError(err instanceof Error ? err.message : String(err));
} finally {
setTestBusy(false);
}
};
const onArm = () => {
const id = stepId.trim();
if (!id) { setTestError('step_id requis'); return; }
void runTest(() => armStep(board, id));
};
const onPlay = () => {
const p = mediaPath.trim();
if (!p) { setTestError('chemin média requis'); return; }
void runTest(() => playMedia(board, p));
};
const dot = (s: string) => (s === 'ok' ? '#34d399' : s === 'error' ? '#fca5a5' : '#fbbf24');
const inputStyle = {
flex: 1, minWidth: 0, background: '#1f1f22', color: C.text,
border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px',
} as const;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ color: C.sub }}>
Pousse les voix générées (onglet Voix) sur la SD dune carte : <code>/sdcard/zacus/&lt;carte&gt;/</code>.
Carte avec IP push direct ; sans IP bundle à copier sur la SD.
</span>
<select value={board} onChange={(e) => setBoard(e.target.value)}
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
{boards.length === 0 && <option value="">(aucune carte gateway ?)</option>}
{boards.map((b) => <option key={b.name} value={b.name}>{b.label} {b.ip ? `(${b.ip})` : '(hors-ligne)'}</option>)}
</select>
<button type="button" onClick={onStage} disabled={busy || !board}
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
opacity: !board ? 0.4 : 1 }}>
{busy ? 'Déploiement…' : 'Déployer les assets sur la carte'}
</button>
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
{result && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, background: '#1a1a1d',
border: `1px solid ${C.border}`, borderRadius: 4, padding: 8 }}>
<span style={{ color: result.ok ? '#34d399' : '#fca5a5' }}>
{result.strategy_used.toUpperCase()} {result.base_path} {result.ok ? '✓' : '✗'}
</span>
{result.steps.map((s, i) => (
<span key={i} style={{ color: C.sub }}>
<span style={{ color: dot(s.status) }}></span> {s.asset} {s.detail}
</span>
))}
</div>
)}
<div style={{ borderTop: `1px solid ${C.border}`, marginTop: 4, paddingTop: 8,
display: 'flex', flexDirection: 'column', gap: 8 }}>
<strong style={{ color: C.text }}>Tester sur la carte</strong>
<span style={{ color: C.sub }}>
Arme une étape (D2) ou joue un cue média (D3) sur la carte sélectionnée, via la gateway.
</span>
<div style={{ display: 'flex', gap: 6 }}>
<input type="text" value={stepId} onChange={(e) => setStepId(e.target.value)}
placeholder="STEP_intro" aria-label="step_id" style={inputStyle} />
<button type="button" onClick={onArm} disabled={testBusy || !board}
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: testBusy ? C.bg : C.accent, color: '#fff',
cursor: testBusy ? 'wait' : 'pointer', opacity: !board ? 0.4 : 1 }}>
Armer
</button>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input type="text" value={mediaPath} onChange={(e) => setMediaPath(e.target.value)}
placeholder="zacus/master/cue.wav" aria-label="chemin média" style={inputStyle} />
<button type="button" onClick={onPlay} disabled={testBusy || !board}
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: testBusy ? C.bg : C.accent, color: '#fff',
cursor: testBusy ? 'wait' : 'pointer', opacity: !board ? 0.4 : 1 }}>
Jouer
</button>
</div>
{testError && <span style={{ color: '#fca5a5' }}>{testError}</span>}
{testResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, background: '#1a1a1d',
border: `1px solid ${C.border}`, borderRadius: 4, padding: 8 }}>
<span style={{ color: testResult.ok ? '#34d399' : '#fca5a5' }}>
<span style={{ color: dot(testResult.ok ? 'ok' : 'error') }}></span>{' '}
{testResult.route} HTTP {testResult.status} {testResult.ok ? '✓' : '✗'}
{'armed' in testResult ? ` · armé : ${testResult.armed ? 'oui' : 'non'}` : ''}
</span>
{testResult.detail && <span style={{ color: C.sub }}>{testResult.detail}</span>}
</div>
)}
</div>
</div>
);
}
/**
* Tabbed Assets body (text / voice / screen / deploy). Rendered inside the
* menu-bar popover — the open/close state lives in `MenuBar`, so this no longer
* carries its own floating trigger button or window chrome.
*/
export function AssetsBody() {
const [tab, setTab] = useState<Tab>('text');
return (
<div
data-testid="assets-panel"
style={{
width: 340, padding: 12, borderRadius: 8, background: C.bg,
border: `1px solid ${C.accent}`,
display: 'flex', flexDirection: 'column', gap: 10,
fontSize: 12, color: C.text,
}}
>
<strong style={{ color: '#c084fc' }}> Assets</strong>
<div style={{ display: 'flex', gap: 4 }}>
{TABS.map((t) => (
<button key={t.id} type="button" onClick={() => setTab(t.id)}
style={{ flex: 1, padding: '4px 0', border: `1px solid ${tab === t.id ? C.accent : C.border}`,
borderRadius: 5, background: tab === t.id ? C.accent : '#1f1f22',
color: tab === t.id ? '#fff' : C.sub, cursor: 'pointer', fontSize: 11 }}>
{t.label}
</button>
))}
</div>
{tab === 'text' && <TextGenBody />}
{tab === 'voice' && <AudioGenBody />}
{tab === 'screen' && <ScreenEditorBody />}
{tab === 'deploy' && <DeployTab />}
</div>
);
}
@@ -0,0 +1,97 @@
import { useState } from 'react';
import { generateAudio, stagedUrl, type AudioBackend, type AudioGenRequest } from '../lib/audiogen.js';
/**
* Audio (TTS) generation body (P2, hosted in the unified Assets panel).
* Synthesises a line either in the cloned Professor Zacus voice (f5, via the
* voice-bridge) or in a stock voice (kokoro, via vllm-mlx), polls the async
* job, then previews the staged WAV in an <audio> player so the author can
* audition before staging it onto a board (Deploy tab).
*/
const BACKENDS: { value: AudioBackend; label: string; hint: string }[] = [
{ value: 'f5', label: 'Voix Zacus (clonée)', hint: 'Voix du professeur, via voice-bridge F5' },
{ value: 'kokoro', label: 'Voix stock (vllm-mlx)', hint: 'Voix génériques FR/EN, Kokoro' },
];
const KOKORO_VOICES: { value: string; label: string }[] = [
{ value: 'ff_siwis', label: 'FR — Siwis (♀)' },
{ value: 'af_heart', label: 'EN — Heart (♀)' },
{ value: 'af_bella', label: 'EN — Bella (♀)' },
{ value: 'am_adam', label: 'EN — Adam (♂)' },
];
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
export function AudioGenBody() {
const [backend, setBackend] = useState<AudioBackend>('f5');
const [voice, setVoice] = useState('ff_siwis');
const [text, setText] = useState('');
const [key, setKey] = useState('');
const [busy, setBusy] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const onGenerate = async () => {
if (!text.trim()) return;
setBusy(true);
setError(null);
setPreviewUrl(null);
try {
const req: AudioGenRequest = { text: text.trim(), backend };
if (backend === 'kokoro') req.voice = voice;
if (key.trim()) req.key = key.trim();
const job = await generateAudio(req);
if (job.staged_key) setPreviewUrl(`${stagedUrl(job.staged_key)}?t=${Date.now()}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const hint = BACKENDS.find((b) => b.value === backend)?.hint ?? '';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<select value={backend} onChange={(e) => setBackend(e.target.value as AudioBackend)}
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
{BACKENDS.map((b) => <option key={b.value} value={b.value}>{b.label}</option>)}
</select>
<span style={{ color: C.sub, marginTop: -4 }}>{hint}</span>
{backend === 'kokoro' && (
<select value={voice} onChange={(e) => setVoice(e.target.value)}
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
{KOKORO_VOICES.map((v) => <option key={v.value} value={v.value}>{v.label}</option>)}
</select>
)}
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3}
placeholder="Texte à dire (ex. « Je suis le professeur Zacus. »)"
style={{ resize: 'vertical', background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`,
borderRadius: 4, padding: 6, fontFamily: 'inherit', fontSize: 12 }} />
<input value={key} onChange={(e) => setKey(e.target.value)} placeholder="clé/id (optionnel, ex. zacus_intro)"
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }} />
<button type="button" onClick={onGenerate} disabled={busy || !text.trim()}
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
opacity: !text.trim() ? 0.4 : 1 }}>
{busy ? 'Génération…' : 'Générer'}
</button>
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
{previewUrl && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio controls src={previewUrl} style={{ width: '100%' }} />
<a href={previewUrl} download
style={{ color: '#34d399', fontSize: 11, alignSelf: 'flex-start', textDecoration: 'none' }}>
Télécharger le WAV
</a>
</div>
)}
</div>
);
}
@@ -1,6 +1,30 @@
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import * as Blockly from 'blockly';
import { TOOLBOX_XML } from './toolbox.js';
import { WB } from '../theme/workbench.js';
/**
* Workbench Blockly theme — defined once at module scope (defineTheme at the
* top level of a module has no side effects beyond registering the theme, so it
* is safe and avoids re-defining under StrictMode's double-invoked effects).
* All colours route through `WB` (single source of truth — see CLAUDE.md).
*/
const workbenchTheme = Blockly.Theme.defineTheme('zacus-workbench', {
name: 'zacus-workbench',
base: Blockly.Themes.Classic,
componentStyles: {
workspaceBackgroundColour: WB.bg,
toolboxBackgroundColour: WB.panel,
toolboxForegroundColour: WB.text,
flyoutBackgroundColour: WB.panel,
flyoutForegroundColour: WB.label,
scrollbarColour: WB.panelBorder,
insertionMarkerColour: WB.orange,
// Grid colour is set via the inject `grid.colour` option below —
// `gridColour` is not a Blockly v12 ComponentStyle key.
},
fontStyle: { family: WB.fontMono, size: 11 },
});
export interface BlocklyWorkspaceHandle {
getWorkspace: () => Blockly.WorkspaceSvg | null;
@@ -29,9 +53,9 @@ export const BlocklyWorkspace = forwardRef<BlocklyWorkspaceHandle, BlocklyWorksp
const ws = Blockly.inject(containerRef.current, {
toolbox: TOOLBOX_XML,
grid: { spacing: 20, length: 3, colour: '#2c2c2e', snap: true },
grid: { spacing: 24, length: 3, colour: WB.gridSubtle, snap: true },
zoom: { controls: true, wheel: true, startScale: 0.9 },
theme: Blockly.Themes.Classic,
theme: workbenchTheme,
renderer: 'zelos',
trashcan: true,
move: { scrollbars: true, drag: true, wheel: false },
@@ -1,8 +1,5 @@
import { useSimStore, type SimMode } from '../stores/simStore.js';
import { useValidationStore, type ValidationEntry } from '../stores/validationStore.js';
const MODES: SimMode[] = ['sandbox', 'demo', 'test'];
const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
error: '#fca5a5',
warning: '#fcd34d',
@@ -10,8 +7,6 @@ const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
};
export function ConsolePane() {
const mode = useSimStore((s) => s.mode);
const setMode = useSimStore((s) => s.setMode);
const entries = useValidationStore((s) => s.entries);
const errorCount = entries.filter((e) => e.severity === 'error').length;
@@ -20,20 +15,6 @@ export function ConsolePane() {
return (
<div className="atelier-pane atelier-pane--console">
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 12 }}>
<div className="atelier-mode-tabs">
{MODES.map((m) => (
<button
key={m}
type="button"
className={
'atelier-mode-tab' + (m === mode ? ' atelier-mode-tab--active' : '')
}
onClick={() => setMode(m)}
>
{m}
</button>
))}
</div>
<span style={{ marginLeft: 'auto', fontSize: 12, color: '#9a9a9d' }}>
{entries.length === 0
? 'No issues'
@@ -0,0 +1,202 @@
/**
* DeployPanel — operator-facing UI to push the current workspace scenario to
* a board through the zacus-gateway (board selector + « Déployer » button).
*
* Boards without an IP that the master relays over ESP-NOW (PLIP, box3,
* p7_coffre) are flagged in the selector; the deploy is then routed through
* the master automatically (see useDeploy.resolveFlashTarget).
*
* Pure inline-styles per atelier convention (no Tailwind, no atelier-pane*
* class — the panel is self-contained and floats over the editor pane).
*/
import { useEffect, useMemo, useState } from 'react';
import { useDeploy, resolveFlashTarget, type FlashStep } from '../hooks/useDeploy.js';
const STEP_COLORS: Record<FlashStep['status'], string> = {
ok: '#86efac',
warn: '#fcd34d',
skip: '#9a9a9d',
error: '#fca5a5',
};
export function DeployPanel() {
const {
boards,
loadingBoards,
deploying,
result,
error,
routedVia,
deploy,
refetchBoards,
gatewayBaseUrl,
} = useDeploy();
const [selected, setSelected] = useState<string>('');
// Default to the first board once the list lands — never override a choice.
useEffect(() => {
const first = boards[0];
if (!selected && first) setSelected(first.name);
}, [boards, selected]);
const routing = useMemo(
() => (selected ? resolveFlashTarget(selected, boards) : null),
[selected, boards],
);
return (
<div
data-testid="deploy-panel"
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: 12,
background: '#1f1f22',
border: '1px solid #2c2c2e',
borderRadius: 6,
color: '#e8e8e8',
fontSize: 13,
minWidth: 280,
maxWidth: 360,
}}
>
<strong style={{ fontSize: 13, letterSpacing: 0.2 }}>
Déploiement du scénario
</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select
data-testid="deploy-board-select"
value={selected}
disabled={deploying || loadingBoards || boards.length === 0}
onChange={(e) => setSelected(e.target.value)}
style={{
flex: 1,
padding: '6px 8px',
background: '#232327',
color: '#e8e8e8',
border: '1px solid #2c2c2e',
borderRadius: 4,
fontSize: 12,
}}
>
{boards.length === 0 ? (
<option value="">
{loadingBoards ? 'Chargement…' : 'Aucune carte (gateway ?)'}
</option>
) : (
boards.map((b) => {
const r = resolveFlashTarget(b.name, boards);
return (
<option key={b.name} value={b.name}>
{b.label}
{r.viaMaster ? ' — relay ESP-NOW' : ''}
</option>
);
})
)}
</select>
<button
type="button"
onClick={() => void refetchBoards()}
disabled={loadingBoards || deploying}
title="Recharger la liste des cartes"
style={{
padding: '6px 10px',
background: '#232327',
color: '#9a9a9d',
fontSize: 11,
border: '1px solid #2c2c2e',
borderRadius: 4,
cursor: loadingBoards ? 'wait' : 'pointer',
}}
>
</button>
</div>
{routing?.viaMaster ? (
<div style={{ fontSize: 10, color: '#9a9a9d' }}>
Carte sans IP poussée via le master (relay ESP-NOW vers «{' '}
{selected} »).
</div>
) : null}
<button
type="button"
data-testid="deploy-button"
onClick={() => void deploy(selected)}
disabled={deploying || !selected || boards.length === 0}
style={{
padding: '6px 14px',
background: deploying ? '#3c3c4a' : '#0071e3',
color: '#fff',
fontSize: 12,
fontWeight: 600,
border: 'none',
borderRadius: 4,
cursor: deploying || !selected ? 'not-allowed' : 'pointer',
}}
>
{deploying ? 'Déploiement…' : 'Déployer'}
</button>
{error ? (
<div
role="alert"
style={{
fontSize: 11,
color: '#fca5a5',
background: '#2a1414',
padding: '4px 6px',
borderRadius: 4,
}}
>
{error.kind === 'network'
? `Gateway (${gatewayBaseUrl}) injoignable : ${error.message}`
: error.message}
</div>
) : null}
{result ? (
<div
role="status"
data-testid="deploy-result"
style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
>
<div
style={{
fontSize: 11,
padding: '4px 6px',
borderRadius: 4,
background: result.ok ? '#0e3a1a' : '#2a1414',
color: result.ok ? '#86efac' : '#fca5a5',
}}
>
{result.ok ? 'Déployé' : 'Échec'} {result.board} (
{result.strategy_used})
{routedVia ? ` via ${routedVia}` : ''}
{result.relayed_to.length > 0
? ` → relayé à ${result.relayed_to.join(', ')}`
: ''}
</div>
{result.steps.map((s, i) => (
<div
key={i}
style={{
fontSize: 10,
color: STEP_COLORS[s.status] ?? '#9a9a9d',
fontFamily: 'ui-monospace, Menlo, monospace',
}}
>
[{s.status}] {s.label}
{s.detail ? `${s.detail}` : ''}
</div>
))}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,40 @@
import { useSelectionStore } from '../stores/selectionStore.js';
import { type FlowGraphData } from '../lib/irSteps.js';
import { DeviceScreenView } from './DeviceScreenView.js';
import { EMPTY_SCENE } from '../lib/screenSpec.js';
import { WB } from '../theme/workbench.js';
/**
* Device screen preview (phase C): renders the master LCD for the selected
* step (falling back to the entry step) using the shared faithful renderer.
*
* Presentational: the flow graph is parsed once in {@link WorkbenchPane} and
* passed in as a prop; only the selection is read from the store.
*/
export function DeviceScreen({ graph }: { graph: FlowGraphData }) {
const { nodes, entry } = graph;
const selectedStepId = useSelectionStore((s) => s.selectedStepId);
const targetId = selectedStepId ?? entry;
const node = nodes.find((n) => n.id === targetId);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: 8,
height: '100%',
boxSizing: 'border-box',
justifyContent: 'center',
}}
>
<div style={{ color: WB.label, font: `11px ${WB.fontMono}`, letterSpacing: 1 }}>
ÉCRAN · {node?.id ?? '—'}
</div>
<DeviceScreenView scene={node?.scene ?? EMPTY_SCENE} scale={0.7} />
</div>
);
}
@@ -0,0 +1,57 @@
import { SCREEN, type SceneDraft, type ScreenEffect } from '../lib/screenSpec.js';
/**
* Faithful render of the firmware LCD scene (ST7796 480×320, Workbench
* palette, per-field fonts and effect animations). Extracted from
* ScreenEditorPanel (P3a) so both the scene editor and the Workbench device
* preview share one pixel-accurate renderer. Parameterised by `scale`.
*/
function symbolAnimation(effect: ScreenEffect): string | undefined {
if (effect === 'pulse') return 'zacus-pulse 2.4s ease-in-out infinite';
if (effect === 'glitch') return 'zacus-glitch 0.45s steps(2) infinite';
return undefined; // gyro is drawn as a ring; none = static
}
export function DeviceScreenView({ scene, scale = 0.66 }: { scene: SceneDraft; scale?: number }) {
return (
<div style={{ width: SCREEN.width * scale, height: SCREEN.height * scale, alignSelf: 'center' }}>
<div style={{
width: SCREEN.width, height: SCREEN.height, transform: `scale(${scale})`, transformOrigin: 'top left',
background: SCREEN.bg, position: 'relative', borderRadius: 6, overflow: 'hidden',
boxShadow: 'inset 0 0 0 2px rgba(0,0,0,0.4)',
}}>
{/* Title — Orbitron 40, white, top-center */}
<div style={{
position: 'absolute', top: 24, left: 12, right: 12, textAlign: 'center',
color: SCREEN.title.color, font: `700 40px ${SCREEN.title.font}`,
letterSpacing: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{scene.title}</div>
{/* Symbol / code slot — Orbitron 40, orange, center */}
<div style={{
position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
display: 'flex', justifyContent: 'center', alignItems: 'center',
}}>
{scene.effect === 'gyro' && scene.symbol && (
<span style={{
position: 'absolute', width: 120, height: 120, borderRadius: '50%',
border: `3px solid ${SCREEN.symbol.color}`, borderTopColor: 'transparent',
animation: 'zacus-gyro-spin 3s linear infinite',
}} />
)}
<span style={{
color: SCREEN.symbol.color, font: `700 40px ${SCREEN.symbol.font}`, letterSpacing: 3,
animation: symbolAnimation(scene.effect),
}}>{scene.symbol}</span>
</div>
{/* Subtitle — IBM Plex Mono 18, grey, bottom-left */}
<div style={{
position: 'absolute', bottom: 10, left: 8, right: 8,
color: SCREEN.subtitle.color, font: `18px ${SCREEN.subtitle.font}`,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{scene.subtitle}</div>
</div>
</div>
);
}
@@ -3,6 +3,7 @@ import type * as Blockly from 'blockly';
import type { BlocklyWorkspaceHandle } from './BlocklyWorkspace.js';
import { useEditorStore } from '../stores/editorStore.js';
import { useValidationStore } from '../stores/validationStore.js';
import { useWorkspaceStore } from '../stores/workspaceStore.js';
const BlocklyWorkspaceLazy = lazy(async () => {
const blocksMod = await import('../blocks/index.js');
@@ -32,20 +33,32 @@ export function EditorPane() {
};
}, []);
// Release the workspace handle when the editor unmounts so MenuBar actions
// never target a disposed Blockly instance.
useEffect(() => {
return () => useWorkspaceStore.getState().setWorkspace(null);
}, []);
const handleWorkspaceChange = useCallback(
async (ws: Blockly.WorkspaceSvg) => {
const { exportWorkspaceToYaml } = await import('../lib/yaml-export.js');
const yaml = exportWorkspaceToYaml(ws);
// Register the live workspace once so MenuBar can import scenarios into
// it (getState avoids re-subscribing this hot change listener).
if (useWorkspaceStore.getState().workspace !== ws) {
useWorkspaceStore.getState().setWorkspace(ws);
}
const { workspaceToBlocksNodes } = await import('../lib/yaml-export.js');
const { serializeBlocksDoc } = await import('@zacus/shared');
const nodes = workspaceToBlocksNodes(ws);
const yaml = serializeBlocksDoc(nodes);
setBlocklyJson(yaml);
clearValidation('schema');
clearValidation('compile');
try {
const { parseScenarioYaml } = await import('@zacus/shared');
const { validateScenario } = await import('../lib/validator.js');
const scenario = parseScenarioYaml(yaml);
const result = validateScenario(scenario);
const { validateBlocksNodes } = await import('../lib/validator.js');
const result = validateBlocksNodes(nodes);
result.errors.forEach((msg, i) =>
pushValidation({
id: `schema-e-${i}`,
@@ -0,0 +1,202 @@
import { useCallback, useMemo } from 'react';
import {
Background,
Controls,
Handle,
MarkerType,
Position,
ReactFlow,
type Edge,
type Node,
type NodeProps,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useSelectionStore } from '../stores/selectionStore.js';
import { type FlowGraphData } from '../lib/irSteps.js';
import { WB } from '../theme/workbench.js';
/**
* Scenario flow view (phase C, V2): a *spatial* vertical graph rendered with
* React Flow. Steps are laid out top-down by BFS depth from the entry; clicking
* a node selects it (drives the device-screen preview). The entry step is
* marked ▶, the selected step is highlighted in {@link WB.orange}.
*
* Presentational: the flow graph is parsed once in {@link WorkbenchPane} and
* passed in as a prop; only the selection is read/written via the store.
*/
const LEVEL_GAP_Y = 96;
const SIBLING_GAP_X = 200;
/** Data carried by each React Flow node. */
interface FlowNodeData extends Record<string, unknown> {
label: string;
symbol: string;
isEntry: boolean;
selected: boolean;
}
/** Custom Workbench-styled node with vertical source/target handles. */
function StepNode({ data }: NodeProps<Node<FlowNodeData>>) {
const { label, symbol, isEntry, selected } = data;
return (
<div
style={{
minWidth: 120,
padding: '6px 10px',
border: `1px solid ${selected ? WB.orange : WB.panelBorder}`,
borderRadius: 5,
background: selected ? 'rgba(255,136,0,0.16)' : WB.panel,
color: selected ? WB.orange : WB.text,
font: `12px ${WB.fontMono}`,
display: 'flex',
gap: 6,
alignItems: 'center',
boxShadow: selected ? `0 0 0 1px ${WB.orange}` : 'none',
}}
>
<Handle type="target" position={Position.Top} style={{ background: WB.panelBorder }} />
<span style={{ color: WB.orange, width: 12 }}>{isEntry ? '▶' : ''}</span>
<span>{label}</span>
{symbol && <span style={{ color: WB.label }}>{symbol}</span>}
<Handle type="source" position={Position.Bottom} style={{ background: WB.panelBorder }} />
</div>
);
}
const nodeTypes: NodeTypes = { step: StepNode };
/**
* Assign a vertical depth to each node by BFS from the entry. Unreachable nodes
* are appended at deeper levels so they are never lost. Back-edges (cycles) are
* tolerated: a `seen` set prevents re-enqueueing.
*/
function computeDepths(graph: FlowGraphData): Map<string, number> {
const adjacency = new Map<string, string[]>();
for (const e of graph.edges) {
const list = adjacency.get(e.from);
if (list) list.push(e.to);
else adjacency.set(e.from, [e.to]);
}
const depth = new Map<string, number>();
const seen = new Set<string>();
const queue: Array<{ id: string; d: number }> = [];
const enqueueRoot = (id: string, d: number) => {
if (seen.has(id)) return;
seen.add(id);
queue.push({ id, d });
};
if (graph.entry) enqueueRoot(graph.entry, 0);
// Drain the BFS, then seed any still-unvisited node as a new root one level
// below the current deepest level, until every node has a depth.
const allIds = graph.nodes.map((n) => n.id);
let idx = 0;
while (seen.size < allIds.length || queue.length > 0) {
if (queue.length === 0) {
// Pick the next unseen node as a fresh root below the deepest known level.
while (idx < allIds.length && seen.has(allIds[idx]!)) idx += 1;
if (idx >= allIds.length) break;
let maxDepth = -1;
for (const d of depth.values()) if (d > maxDepth) maxDepth = d;
enqueueRoot(allIds[idx]!, maxDepth + 1);
}
const cur = queue.shift();
if (cur === undefined) continue;
depth.set(cur.id, cur.d);
for (const next of adjacency.get(cur.id) ?? []) enqueueRoot(next, cur.d + 1);
}
return depth;
}
export function FlowGraph({ graph }: { graph: FlowGraphData }) {
const selectedStepId = useSelectionStore((s) => s.selectedStepId);
const selectStep = useSelectionStore((s) => s.selectStep);
const rfNodes = useMemo<Array<Node<FlowNodeData>>>(() => {
if (graph.nodes.length === 0) return [];
const depth = computeDepths(graph);
// Group node ids by depth level to spread siblings horizontally.
const byLevel = new Map<number, string[]>();
for (const node of graph.nodes) {
const d = depth.get(node.id) ?? 0;
const list = byLevel.get(d);
if (list) list.push(node.id);
else byLevel.set(d, [node.id]);
}
const indexInLevel = new Map<string, number>();
for (const ids of byLevel.values()) {
ids.forEach((id, i) => indexInLevel.set(id, i));
}
return graph.nodes.map((node) => {
const d = depth.get(node.id) ?? 0;
const level = byLevel.get(d) ?? [];
const i = indexInLevel.get(node.id) ?? 0;
const x = (i - (level.length - 1) / 2) * SIBLING_GAP_X;
const y = d * LEVEL_GAP_Y;
return {
id: node.id,
type: 'step',
position: { x, y },
data: {
label: node.id,
symbol: node.scene.symbol,
isEntry: node.id === graph.entry,
selected: node.id === selectedStepId,
},
};
});
}, [graph, selectedStepId]);
const rfEdges = useMemo<Edge[]>(() => {
return graph.edges.map((e, i) => {
const active = e.from === selectedStepId;
const color = active ? WB.orange : WB.label;
return {
id: `${e.from}->${e.to}-${i}`,
source: e.from,
target: e.to,
type: 'smoothstep',
style: { stroke: color, strokeWidth: active ? 2 : 1 },
markerEnd: { type: MarkerType.ArrowClosed, color },
};
});
}, [graph.edges, selectedStepId]);
const onNodeClick = useCallback(
(_e: React.MouseEvent, node: Node) => selectStep(node.id),
[selectStep],
);
if (graph.nodes.length === 0) {
return (
<div style={{ padding: 12, color: WB.label, font: `12px ${WB.fontMono}` }}>
Aucune étape édite le scénario puis lance (Run).
</div>
);
}
return (
<div style={{ height: '100%', width: '100%' }}>
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
fitView
proOptions={{ hideAttribution: true }}
style={{ background: WB.bg }}
>
<Background color={WB.gridSubtle} gap={20} />
<Controls />
</ReactFlow>
</div>
);
}
@@ -6,8 +6,8 @@ import {
type ImperativePanelHandle,
} from 'react-resizable-panels';
import { EditorPane } from './EditorPane.js';
import { StagePane } from './StagePane.js';
import { ConsolePane } from './ConsolePane.js';
import { WorkbenchPane } from './WorkbenchPane.js';
/**
* Atelier shell: 2 columns top + 1 console row bottom.
@@ -65,7 +65,9 @@ export function Layout() {
collapsible
collapsedSize={0}
>
<StagePane />
<div data-testid="stage-pane" style={{ height: '100%' }}>
<WorkbenchPane />
</div>
</Panel>
</PanelGroup>
</Panel>
@@ -0,0 +1,294 @@
import { useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
import { useEditorStore } from '../stores/editorStore.js';
import { useWorkspaceStore } from '../stores/workspaceStore.js';
import { DEFAULT_SCENARIOS, type DefaultScenario } from '../lib/defaultScenarios.js';
import { WB } from '../theme/workbench.js';
import { GameConfigPanel } from './GameConfigPanel.js';
import { DeployPanel } from './DeployPanel.js';
import { AssetsBody } from './AssetsPanel.js';
/** Workbench panels reachable from the menu bar. One open at a time. */
type PanelId = 'config' | 'deploy' | 'assets';
const PANEL_LABELS: Record<PanelId, string> = {
config: 'Configuration de groupe',
deploy: 'Déploiement',
assets: 'Assets',
};
/** Workbench lab title — Orbitron, orange, wide tracking. */
const labTitleStyle: CSSProperties = {
fontFamily: WB.fontTitle,
fontWeight: 700,
fontSize: 13,
letterSpacing: 2,
color: WB.orange,
};
/** Bar chrome: dark panel with a workbench-blue baseline. */
const barStyle: CSSProperties = {
background: WB.panel,
borderBottom: `1px solid ${WB.panelBorder}`,
};
/** Shared button chrome (hover handled inline). */
const btnStyle: CSSProperties = {
background: WB.panel,
border: `1px solid ${WB.panelBorder}`,
color: WB.text,
};
const onBtnEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.borderColor = WB.orange;
};
const onBtnLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.borderColor = WB.panelBorder;
};
/** Trigger a client-side download of a text blob. */
function downloadText(filename: string, text: string, mime = 'text/yaml'): void {
const url = URL.createObjectURL(new Blob([text], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/** `20260613-1135` — stable, filesystem-safe scenario filename suffix. */
function timestampSlug(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
`-${pad(d.getHours())}${pad(d.getMinutes())}`
);
}
/**
* Top menu bar: load / save (and later share) a scenario. The scenario YAML
* is the `blocks_studio v2` interop format — the same one the gateway compiles
* to Runtime 3 IR and the zacus-hub Studio reads/writes.
*
* Save reads the already-serialized YAML from `editorStore`; load injects a
* picked `.yaml` back into the live Blockly workspace (`workspaceStore`).
*/
export function MenuBar() {
const fileRef = useRef<HTMLInputElement>(null);
const yaml = useEditorStore((s) => s.blocklyJson);
const hasWorkspace = useWorkspaceStore((s) => s.workspace !== null);
const [notice, setNotice] = useState<string | null>(null);
const [sharing, setSharing] = useState(false);
const [openList, setOpenList] = useState(false);
const [openPanel, setOpenPanel] = useState<PanelId | null>(null);
/** Open one panel popover (closing any other) or toggle the current one shut. */
const togglePanel = (id: PanelId) => {
setOpenList(false);
setOpenPanel((cur) => (cur === id ? null : id));
};
const flash = (msg: string) => {
setNotice(msg);
window.setTimeout(() => setNotice(null), 4000);
};
/** Import a YAML document into the live workspace, reporting via the notice. */
const importYaml = async (text: string, okPrefix: string): Promise<void> => {
const ws = useWorkspaceStore.getState().workspace;
if (!ws) {
flash('Éditeur pas encore prêt');
return;
}
try {
const { importScenarioToWorkspace } = await import('../lib/yaml-import.js');
const { created, source, warnings } = importScenarioToWorkspace(ws, text);
const plural = created > 1 ? 's' : '';
if (source === 'runtime3') {
const extra = warnings.length > 0 ? `${warnings.length} avertissement${warnings.length > 1 ? 's' : ''}` : '';
if (warnings.length > 0) console.warn('[runtime3-import]', ...warnings);
flash(`${okPrefix} (Runtime 3) : ${created} bloc${plural}${extra}`);
} else {
flash(`${okPrefix} : ${created} bloc${plural}`);
}
} catch (err) {
flash(`Échec : ${err instanceof Error ? err.message : String(err)}`);
}
};
const onOpenScenario = (scenario: DefaultScenario) => {
setOpenList(false);
void importYaml(scenario.yaml, `« ${scenario.name} »`);
};
const onSave = () => {
const doc = yaml?.trim();
if (!doc) {
flash('Espace vide — rien à enregistrer');
return;
}
downloadText(`zacus-scenario-${timestampSlug()}.yaml`, doc);
flash('Scénario téléchargé');
};
const onShare = async () => {
const doc = yaml?.trim();
if (!doc) {
flash('Espace vide — rien à partager');
return;
}
setSharing(true);
try {
const { createShare, shareLinkFor } = await import('../lib/share.js');
const link = shareLinkFor(await createShare(doc));
try {
await navigator.clipboard.writeText(link);
flash(`Lien copié : ${link}`);
} catch {
flash(`Lien de partage : ${link}`);
}
} catch (err) {
flash(`Partage échoué : ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSharing(false);
}
};
const onLoadClick = () => fileRef.current?.click();
const onFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-selecting the same file later
if (!file) return;
await importYaml(await file.text(), 'Chargé');
};
return (
<div className="atelier-menubar" style={barStyle}>
<span className="atelier-menubar__title" style={labTitleStyle}>
ZACUS · ATELIER
</span>
<div className="atelier-menubar__group">
<div className="atelier-menubar__dropdown">
<button
type="button"
className="atelier-menubar__btn"
style={btnStyle}
onMouseEnter={onBtnEnter}
onMouseLeave={onBtnLeave}
onClick={() => {
setOpenPanel(null);
setOpenList((v) => !v);
}}
disabled={!hasWorkspace}
title="Ouvrir un scénario de départ"
aria-expanded={openList}
>
Ouvrir
</button>
{openList && (
<>
<div
className="atelier-menubar__backdrop"
onClick={() => setOpenList(false)}
/>
<ul className="atelier-menubar__list" role="menu">
{DEFAULT_SCENARIOS.map((s) => (
<li key={s.id} role="none">
<button
type="button"
role="menuitem"
className="atelier-menubar__item"
onClick={() => onOpenScenario(s)}
title={s.description}
>
<span className="atelier-menubar__item-name">{s.name}</span>
<span className="atelier-menubar__item-desc">{s.description}</span>
</button>
</li>
))}
</ul>
</>
)}
</div>
<button
type="button"
className="atelier-menubar__btn"
style={btnStyle}
onMouseEnter={onBtnEnter}
onMouseLeave={onBtnLeave}
onClick={onLoadClick}
disabled={!hasWorkspace}
title="Charger un scénario .yaml dans l'éditeur"
>
Charger
</button>
<button
type="button"
className="atelier-menubar__btn"
style={btnStyle}
onMouseEnter={onBtnEnter}
onMouseLeave={onBtnLeave}
onClick={onSave}
disabled={!yaml?.trim()}
title="Télécharger le scénario courant en .yaml"
>
Enregistrer
</button>
<button
type="button"
className="atelier-menubar__btn"
style={btnStyle}
onMouseEnter={onBtnEnter}
onMouseLeave={onBtnLeave}
onClick={onShare}
disabled={!yaml?.trim() || sharing}
title="Publier le scénario et copier un lien de partage"
>
{sharing ? 'Partage…' : 'Partager'}
</button>
</div>
<div className="atelier-menubar__group atelier-menubar__dropdown">
{(Object.keys(PANEL_LABELS) as PanelId[]).map((id) => (
<button
key={id}
type="button"
className={`atelier-menubar__btn${openPanel === id ? ' atelier-menubar__btn--active' : ''}`}
style={btnStyle}
onMouseEnter={onBtnEnter}
onMouseLeave={onBtnLeave}
onClick={() => togglePanel(id)}
aria-expanded={openPanel === id}
title={`Ouvrir : ${PANEL_LABELS[id]}`}
>
{PANEL_LABELS[id]}
</button>
))}
{openPanel && (
<>
<div
className="atelier-menubar__backdrop"
onClick={() => setOpenPanel(null)}
/>
<div className="atelier-menubar__panel" role="dialog" aria-label={PANEL_LABELS[openPanel]}>
{openPanel === 'config' && <GameConfigPanel />}
{openPanel === 'deploy' && <DeployPanel />}
{openPanel === 'assets' && <AssetsBody />}
</div>
</>
)}
</div>
{notice && <span className="atelier-menubar__notice">{notice}</span>}
<input
ref={fileRef}
type="file"
accept=".yaml,.yml,text/yaml"
onChange={onFile}
style={{ display: 'none' }}
/>
</div>
);
}
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { generateText } from '../lib/textgen.js';
import { DeviceScreenView } from './DeviceScreenView.js';
import {
EFFECTS, EMPTY_SCENE, SCREEN, overLimit, sceneToYaml,
type SceneDraft, type ScreenEffect,
} from '../lib/screenSpec.js';
/**
* LCD screen (scene) editor body (P3a, hosted in the unified Assets panel).
* Edits a step's scene (title/subtitle/symbol/effect) with a faithful preview
* of the ST7796 480×320 render (via the shared DeviceScreenView), enforces the
* firmware char limits, can draft screen text via ailiance, and emits the
* `scene:` YAML block to paste onto a scenario step.
*/
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
function CharField({ label, field, value, onChange }: {
label: string; field: 'title' | 'subtitle' | 'symbol'; value: string;
onChange: (v: string) => void;
}) {
const over = overLimit(field, value);
const max = field === 'title' ? SCREEN.title.max : field === 'subtitle' ? SCREEN.subtitle.max : SCREEN.symbol.max;
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ display: 'flex', justifyContent: 'space-between', color: C.sub }}>
<span>{label}</span>
<span style={{ color: over ? '#fca5a5' : C.sub }}>{value.length}/{max}</span>
</span>
<input value={value} onChange={(e) => onChange(e.target.value)}
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${over ? '#b45454' : C.border}`,
borderRadius: 4, padding: '4px 6px', fontSize: 12 }} />
</label>
);
}
export function ScreenEditorBody() {
const [scene, setScene] = useState<SceneDraft>(EMPTY_SCENE);
const [prompt, setPrompt] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const set = <K extends keyof SceneDraft>(key: K, value: SceneDraft[K]) =>
setScene((s) => ({ ...s, [key]: value }));
const onGenerate = async () => {
if (!prompt.trim()) return;
setBusy(true);
setError(null);
try {
const r = await generateText('scene_text', prompt.trim());
set('title', r.text.slice(0, SCREEN.title.max));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const copyYaml = async () => {
await navigator.clipboard.writeText(sceneToYaml(scene)).catch(() => undefined);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<DeviceScreenView scene={scene} />
<CharField label="Titre" field="title" value={scene.title} onChange={(v) => set('title', v)} />
<CharField label="Sous-titre" field="subtitle" value={scene.subtitle} onChange={(v) => set('subtitle', v)} />
<CharField label="Symbole" field="symbol" value={scene.symbol} onChange={(v) => set('symbol', v)} />
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ color: C.sub }}>Effet</span>
<select value={scene.effect} onChange={(e) => set('effect', e.target.value as ScreenEffect)}
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
{EFFECTS.map((fx) => <option key={fx.value} value={fx.value}>{fx.label}</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<input value={prompt} onChange={(e) => setPrompt(e.target.value)}
placeholder="Décris la scène → titre (IA)…"
style={{ flex: 1, background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`,
borderRadius: 4, padding: '4px 6px', fontSize: 12 }} />
<button type="button" onClick={onGenerate} disabled={busy || !prompt.trim()}
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
opacity: !prompt.trim() ? 0.4 : 1, whiteSpace: 'nowrap' }}>
{busy ? '…' : '✶ IA'}
</button>
</div>
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
<button type="button" onClick={copyYaml}
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: C.bg, color: C.text, cursor: 'pointer' }}>
{copied ? '✓ Copié' : 'Copier le YAML scene'}
</button>
</div>
);
}
@@ -1,91 +0,0 @@
import { lazy, Suspense, useCallback } from 'react';
import { useRuntimeStore } from '../stores/runtimeStore.js';
import { useSimStore } from '../stores/simStore.js';
import { useValidationStore } from '../stores/validationStore.js';
const RoomSceneLazy = lazy(async () => {
const mod = await import('../scene/RoomScene.js');
return { default: mod.RoomScene };
});
const SandboxModeLazy = lazy(async () => {
const mod = await import('../modes/SandboxMode.js');
return { default: mod.SandboxMode };
});
const DemoModeLazy = lazy(async () => {
const mod = await import('../modes/DemoMode.js');
return { default: mod.DemoMode };
});
const TestModeLazy = lazy(async () => {
const mod = await import('../modes/TestMode.js');
return { default: mod.TestMode };
});
export function StagePane() {
const isStale = useRuntimeStore((s) => s.isStale);
const pendingIr = useRuntimeStore((s) => s.pendingIr);
const commitPendingIr = useRuntimeStore((s) => s.commitPendingIr);
const mode = useSimStore((s) => s.mode);
const loadScenario = useSimStore((s) => s.loadScenario);
const hasErrors = useValidationStore((s) =>
s.entries.some((e) => e.severity === 'error'),
);
const handleRun = useCallback(() => {
if (!pendingIr || hasErrors) return;
loadScenario(pendingIr);
commitPendingIr();
}, [pendingIr, hasErrors, loadScenario, commitPendingIr]);
const ModeOverlay =
mode === 'demo' ? DemoModeLazy : mode === 'test' ? TestModeLazy : SandboxModeLazy;
return (
<div
data-testid="stage-pane"
style={{ position: 'relative', height: '100%', background: '#000' }}
>
<Suspense
fallback={
<div className="atelier-pane atelier-pane--stage">
<span>Loading 3D engine</span>
</div>
}
>
<RoomSceneLazy />
<ModeOverlay />
</Suspense>
{isStale ? (
<button
type="button"
onClick={handleRun}
disabled={hasErrors}
title={
hasErrors
? 'Corrige les erreurs de validation pour relancer'
: 'Recompiler et rejouer la scène avec le scenario édité'
}
style={{
position: 'absolute',
top: 8,
right: 8,
padding: '6px 14px',
background: hasErrors ? '#6b6b70' : '#fbbf24',
color: '#1a1a1d',
fontSize: 12,
fontWeight: 700,
border: 'none',
borderRadius: 4,
cursor: hasErrors ? 'not-allowed' : 'pointer',
zIndex: 20,
boxShadow: hasErrors ? 'none' : '0 2px 6px rgba(0,0,0,0.4)',
}}
>
{hasErrors ? '⚠ stale (errors)' : '▶ Run'}
</button>
) : null}
</div>
);
}
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { generateText, type TextGenKind } from '../lib/textgen.js';
/**
* Text-asset generation body (P1, hosted in the unified Assets panel). Generates
* in-voice NPC lines, progressive hints or screen text via the gateway →
* ailiance LLM, with a copy-to-clipboard so the author drops it into a block,
* npc_phrases.yaml or hints_adaptive.yaml.
*/
const KINDS: { value: TextGenKind; label: string; placeholder: string }[] = [
{ value: 'npc_reply', label: 'Réplique NPC', placeholder: 'Contexte : le joueur vient de résoudre l’énigme du diapason…' },
{ value: 'hint', label: 'Indice', placeholder: 'Énigme : piano L-E-F-O-U. Le joueur est bloqué…' },
{ value: 'scene_text', label: 'Texte d’écran', placeholder: 'Scène : intro du laboratoire, ambiance mystérieuse…' },
{ value: 'freeform', label: 'Libre', placeholder: 'Décris ce que tu veux générer…' },
];
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
export function TextGenBody() {
const [kind, setKind] = useState<TextGenKind>('npc_reply');
const [prompt, setPrompt] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const onGenerate = async () => {
if (!prompt.trim()) return;
setBusy(true);
setError(null);
setResult(null);
try {
const r = await generateText(kind, prompt.trim());
setResult(r.text);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const copy = async () => {
if (result) await navigator.clipboard.writeText(result).catch(() => undefined);
};
const ph = KINDS.find((k) => k.value === kind)?.placeholder ?? '';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<select value={kind} onChange={(e) => setKind(e.target.value as TextGenKind)}
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
{KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={ph} rows={3}
style={{ resize: 'vertical', background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`,
borderRadius: 4, padding: 6, fontFamily: 'inherit', fontSize: 12 }} />
<button type="button" onClick={onGenerate} disabled={busy || !prompt.trim()}
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
opacity: !prompt.trim() ? 0.4 : 1 }}>
{busy ? 'Génération…' : 'Générer'}
</button>
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
{result && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ background: '#1a1a1d', border: `1px solid ${C.border}`, borderRadius: 4,
padding: 8, whiteSpace: 'pre-wrap', maxHeight: 160, overflowY: 'auto' }}>{result}</div>
<button type="button" onClick={copy}
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
background: C.bg, color: C.text, cursor: 'pointer', alignSelf: 'flex-start' }}>
Copier
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { DeviceScreen } from './DeviceScreen.js';
import { FlowGraph } from './FlowGraph.js';
import { useRuntimeStore } from '../stores/runtimeStore.js';
import { parseIr } from '../lib/irSteps.js';
import { WB } from '../theme/workbench.js';
/**
* Workbench pane (phase C): the right column of the atelier. A vertical split
* with the faithful device-screen preview on top and the scenario flow graph
* below.
*
* The editor document is parsed into a flow graph exactly once here, then
* shared with both children (device preview + flow graph) as a prop.
*/
export function WorkbenchPane() {
const ir = useRuntimeStore((s) => s.currentIr ?? s.pendingIr);
const graph = useMemo(() => parseIr(ir), [ir]);
return (
<div
className="atelier-pane"
style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}
>
<div
style={{
padding: '8px 12px',
color: WB.orange,
font: `700 13px ${WB.fontTitle}`,
letterSpacing: 2,
borderBottom: `1px solid ${WB.panelBorder}`,
}}
>
BANC D'ESSAI
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<PanelGroup direction="vertical">
<Panel defaultSize={45} minSize={20}>
<DeviceScreen graph={graph} />
</Panel>
<PanelResizeHandle className="atelier-resizer atelier-resizer--horizontal" />
<Panel defaultSize={55} minSize={20}>
<FlowGraph graph={graph} />
</Panel>
</PanelGroup>
</div>
</div>
);
}
@@ -1,22 +1,12 @@
export const TOOLBOX_XML = `
<xml>
<category name="Puzzles" colour="#FF6B35">
<block type="puzzle_sequence_sonore"/>
<block type="puzzle_circuit_led"/>
<block type="puzzle_qr_treasure"/>
<block type="puzzle_radio"/>
<block type="puzzle_morse"/>
<block type="puzzle_symboles_nfc"/>
<block type="puzzle_coffre_final"/>
</category>
<category name="NPC" colour="#5B4FD6">
<block type="npc_profiling"/>
<block type="npc_duration"/>
<block type="npc_adaptive_rule"/>
</category>
<category name="Flow" colour="#2ECC71">
<block type="phase_container"/>
<block type="puzzle_selector"/>
</category>
</xml>
`;
import { ZACUS_TOOLBOX_CATEGORIES } from '@zacus/shared/blockly';
/**
* Toolbox XML generated from the shared catalog — categories and colours
* stay in lockstep with the zacus-hub native Studio.
*/
export const TOOLBOX_XML = `<xml>${ZACUS_TOOLBOX_CATEGORIES.map(
(c) =>
`<category name="${c.name}" colour="${c.colour}">${c.types
.map((t) => `<block type="${t}"/>`)
.join('')}</category>`,
).join('')}</xml>`;
@@ -0,0 +1,247 @@
/**
* useDeploy — push the current Blockly workspace scenario to a board via the
* zacus-gateway (tools/zacus-gateway).
*
* Flow per deploy:
* 1. PUT /v1/studio/scenario/atelier_live.yaml { yaml } (save + validate)
* 2. POST /v1/flash/{target} { scenario, strategy }
*
* Routing: a board with no IP (e.g. PLIP, box3) is not reachable over HTTP,
* but the master relays scenarios to its `espnow_relay_peers` over ESP-NOW.
* When the selected board has no IP and the master lists it as a relay peer,
* the flash is routed through the master (strategy "hot"); the gateway then
* POSTs /game/scenario/relay on the master, which chunks the IR to the peers.
*
* Configuration (Vite env, all optional):
* VITE_GATEWAY_BASE_URL base URL of the zacus-gateway
* (default: http://localhost:8400)
* VITE_GATEWAY_TOKEN bearer token (see tools/zacus-gateway/gen_token.py)
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEditorStore } from '../stores/editorStore.js';
export const DEPLOY_SCENARIO_NAME = 'atelier_live.yaml';
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
export interface BoardInfo {
name: string;
label: string;
type: string;
ip: string | null;
mdns: string | null;
hot_endpoint: string | null;
cold_data_dir: string | null;
espnow_relay_peers: string[];
}
export interface FlashStep {
label: string;
status: 'ok' | 'warn' | 'skip' | 'error';
detail: string;
}
export interface FlashResult {
board: string;
strategy_used: string;
ok: boolean;
steps: FlashStep[];
ir_path: string | null;
cold_command: string | null;
relayed_to: string[];
}
export type DeployError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; message: string }
| { kind: 'empty'; message: string };
export interface UseDeployResult {
/** Boards declared in the gateway's boards.yaml (empty until loaded). */
boards: BoardInfo[];
/** True while the board list is loading. */
loadingBoards: boolean;
/** True while a deploy is in flight. */
deploying: boolean;
/** Last flash result (null until the first deploy). */
result: FlashResult | null;
/** Last error (board list or deploy). */
error: DeployError | null;
/** Board name the last deploy was actually flashed through (master when
* relayed), null when it targeted the selected board directly. */
routedVia: string | null;
/** Push the current workspace scenario to `boardName`. True on success. */
deploy: (boardName: string) => Promise<boolean>;
/** Re-pull the board list. */
refetchBoards: () => Promise<void>;
/** Resolved gateway base URL (env merge). */
gatewayBaseUrl: string;
}
interface ImportMetaEnvLike {
VITE_GATEWAY_BASE_URL?: string | undefined;
VITE_GATEWAY_TOKEN?: string | undefined;
}
function readEnv(): ImportMetaEnvLike {
// Static `import.meta.env.VITE_*` reads so Vite inlines them at build time;
// an aliased/optional-chained access (`(import.meta as any)?.env`) is left as
// a runtime lookup that resolves to undefined in the browser.
return {
VITE_GATEWAY_BASE_URL: import.meta.env.VITE_GATEWAY_BASE_URL,
VITE_GATEWAY_TOKEN: import.meta.env.VITE_GATEWAY_TOKEN,
};
}
/** Resolve the board the flash should go through. Boards without an IP are
* unreachable over HTTP; when the master relays them over ESP-NOW, flash the
* master instead (its relay fan-out includes the requested peer). */
export function resolveFlashTarget(
boardName: string,
boards: BoardInfo[],
): { target: string; strategy: 'hot' | 'auto'; viaMaster: boolean } {
const board = boards.find((b) => b.name === boardName);
if (board && !board.ip) {
const master = boards.find(
(b) => b.ip && b.espnow_relay_peers.includes(boardName),
);
if (master) return { target: master.name, strategy: 'hot', viaMaster: true };
}
return { target: boardName, strategy: 'auto', viaMaster: false };
}
export function useDeploy(): UseDeployResult {
const env = useMemo(() => readEnv(), []);
const gatewayBaseUrl = (env.VITE_GATEWAY_BASE_URL ?? GATEWAY_DEFAULT_BASE_URL)
.replace(/\/+$/, '');
const token = env.VITE_GATEWAY_TOKEN ?? '';
const [boards, setBoards] = useState<BoardInfo[]>([]);
const [loadingBoards, setLoadingBoards] = useState<boolean>(false);
const [deploying, setDeploying] = useState<boolean>(false);
const [result, setResult] = useState<FlashResult | null>(null);
const [error, setError] = useState<DeployError | null>(null);
const [routedVia, setRoutedVia] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const headers = useMemo(
() => ({
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}),
[token],
);
const refetchBoards = useCallback(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoadingBoards(true);
try {
const resp = await fetch(`${gatewayBaseUrl}/v1/flash/boards`, {
headers,
signal: ctrl.signal,
});
if (!resp.ok) {
setError({
kind: 'http',
status: resp.status,
message: `GET /v1/flash/boards → ${resp.status}`,
});
return;
}
setBoards((await resp.json()) as BoardInfo[]);
setError(null);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
const message = err instanceof Error ? err.message : String(err);
setError({ kind: 'network', message });
} finally {
setLoadingBoards(false);
}
}, [gatewayBaseUrl, headers]);
const deploy = useCallback(
async (boardName: string): Promise<boolean> => {
const yaml = useEditorStore.getState().blocklyJson;
if (!yaml || !yaml.trim()) {
setError({
kind: 'empty',
message: 'Espace de travail vide — rien à déployer',
});
return false;
}
setDeploying(true);
setError(null);
setResult(null);
setRoutedVia(null);
try {
// 1. Save + validate the YAML gateway-side.
const put = await fetch(
`${gatewayBaseUrl}/v1/studio/scenario/${DEPLOY_SCENARIO_NAME}`,
{ method: 'PUT', headers, body: JSON.stringify({ yaml }) },
);
if (!put.ok) {
setError({
kind: 'http',
status: put.status,
message: `PUT scénario → ${put.status} ${(await put.text()).slice(0, 200)}`,
});
return false;
}
// 2. Flash, routed through the master for relay-only boards.
const { target, strategy, viaMaster } = resolveFlashTarget(
boardName,
boards,
);
const post = await fetch(`${gatewayBaseUrl}/v1/flash/${target}`, {
method: 'POST',
headers,
body: JSON.stringify({ scenario: DEPLOY_SCENARIO_NAME, strategy }),
});
if (!post.ok) {
setError({
kind: 'http',
status: post.status,
message: `POST flash → ${post.status} ${(await post.text()).slice(0, 200)}`,
});
return false;
}
const flash = (await post.json()) as FlashResult;
setResult(flash);
setRoutedVia(viaMaster ? target : null);
return flash.ok;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError({ kind: 'network', message });
return false;
} finally {
setDeploying(false);
}
},
[gatewayBaseUrl, headers, boards],
);
useEffect(() => {
void refetchBoards();
return () => {
abortRef.current?.abort();
};
}, [refetchBoards]);
return {
boards,
loadingBoards,
deploying,
result,
error,
routedVia,
deploy,
refetchBoards,
gatewayBaseUrl,
};
}
@@ -0,0 +1,78 @@
/**
* Client for the gateway's audio-asset generation endpoints (P2 of the asset
* authoring plan). Generation is async: POST /v1/assets/audio/generate returns
* a job, which we poll via GET /v1/assets/audio/{id}/status until done; the
* resulting WAV is previewed at GET /v1/_staged/{key}. Token-free, same-origin
* in prod (zacus.saillant.cc/api), mirroring the P1 text-gen client.
*/
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
function gatewayBaseUrl(): string {
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
return base.replace(/\/+$/, '');
}
/** f5 = cloned Professor Zacus voice (voice-bridge); kokoro = stock voices (vllm-mlx). */
export type AudioBackend = 'f5' | 'kokoro';
export interface AudioGenRequest {
text: string;
backend: AudioBackend;
voice?: string; // kokoro voice id (ignored by f5)
key?: string; // optional manifest/phrase id; default = auto slug
}
export interface AudioJob {
id: string;
status: 'pending' | 'running' | 'done' | 'error';
backend: string;
staged_key?: string | null;
error?: string | null;
created: number;
updated: number;
}
/** Public URL of a staged clip's WAV (for an <audio> element or download). */
export function stagedUrl(key: string): string {
return `${gatewayBaseUrl()}/v1/_staged/${encodeURIComponent(key)}`;
}
async function startAudioJob(req: AudioGenRequest): Promise<AudioJob> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/audio/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`génération refusée (HTTP ${resp.status})${detail ? `${detail.slice(0, 120)}` : ''}`);
}
return (await resp.json()) as AudioJob;
}
async function fetchAudioJob(id: string): Promise<AudioJob> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/audio/${encodeURIComponent(id)}/status`);
if (!resp.ok) throw new Error(`statut indisponible (HTTP ${resp.status})`);
return (await resp.json()) as AudioJob;
}
/**
* Generate an audio clip and resolve once the job terminates (done | error).
* Polls every `intervalMs`, giving up after `timeoutMs`.
*/
export async function generateAudio(
req: AudioGenRequest,
{ intervalMs = 1500, timeoutMs = 120_000 }: { intervalMs?: number; timeoutMs?: number } = {},
): Promise<AudioJob> {
const job = await startAudioJob(req);
const deadline = Date.now() + timeoutMs;
let current = job;
while (current.status === 'pending' || current.status === 'running') {
if (Date.now() > deadline) throw new Error('délai de génération dépassé');
await new Promise((r) => setTimeout(r, intervalMs));
current = await fetchAudioJob(job.id);
}
if (current.status === 'error') throw new Error(current.error || 'échec de la génération audio');
return current;
}
@@ -0,0 +1,217 @@
/**
* Bundled starter scenarios for the Atelier "Ouvrir" menu. Each is a valid
* `blocks_studio v2` document (the same format Charger/Enregistrer/Partager
* use) so opening one drops a ready-made block graph into the workspace.
*
* They use only block kinds from the shared catalog (@zacus/shared/blockly,
* ZACUS_FIELDS_BY_KIND); unknown fields are ignored on import, so these stay
* forward-compatible with catalog tweaks.
*/
export interface DefaultScenario {
id: string;
name: string;
description: string;
yaml: string;
}
export const DEFAULT_SCENARIOS: readonly DefaultScenario[] = [
{
id: 'mini-enquete',
name: 'Mini-enquête (intro → énigme son → fin)',
description:
"Scénario de démarrage complet : écran maître d'accueil, mot du Professeur, première énigme sonore, transition au bouton, écran de victoire puis fin.",
yaml: `blocks_studio_version: 2
nodes:
- id: intro_start
kind: sceneStart
position: [40, 40]
next: intro_scene
params:
id: intro
- id: intro_scene
kind: scene
position: [40, 130]
next: intro_say
params:
title: "LABORATOIRE ZACUS"
subtitle: "Une transmission vous attend"
symbol: "RUN"
effect: glitch
- id: intro_say
kind: npcSay
position: [40, 220]
next: intro_sound
params:
text: "Bienvenue. Pour réveiller la machine, reproduisez la mélodie d'amorçage."
voice: zacus
emotion: mysterious
rate: 1
- id: intro_sound
kind: puzzleSound
position: [40, 310]
next: intro_goto
params:
id: 1
melody: "60, 62, 64, 65"
tolerance: 1
fragment: "1"
- id: intro_goto
kind: onButton
position: [40, 400]
next: intro_end
params:
button: NEXT
target: victoire
- id: intro_end
kind: sceneEnd
position: [40, 490]
- id: win_start
kind: sceneStart
position: [400, 40]
next: win_scene
params:
id: victoire
- id: win_scene
kind: scene
position: [400, 130]
next: win_sound
params:
title: "SIGNAL STABILISÉ"
subtitle: "Première énigme résolue"
symbol: "WIN"
effect: pulse
- id: win_sound
kind: hwSoundPlay
position: [400, 220]
next: win_end
params:
asset: sting_win
- id: win_end
kind: sceneEnd
position: [400, 310]
`,
},
{
id: 'intro-sonore',
name: 'Intro + énigme sonore',
description: "Accueil du Professeur puis première énigme : reproduire une séquence sonore.",
yaml: `blocks_studio_version: 2
nodes:
- id: s1
kind: sceneStart
position: [40, 40]
next: s2
params:
id: intro
- id: s2
kind: npcSay
position: [40, 130]
next: s3
params:
text: "Bienvenue dans mon laboratoire... le mystère commence !"
- id: s3
kind: hwSoundPlay
position: [40, 220]
next: s4
params:
asset: ambiance_labo
- id: s4
kind: puzzleSound
position: [40, 310]
next: s5
params:
id: P1_SON
melody: "do re mi sol"
tolerance: 1
fragment: A
- id: s5
kind: sceneEnd
position: [40, 410]
`,
},
{
id: 'maitre-du-jeu',
name: 'Maître du jeu adaptatif',
description: "Phase de jeu rythmée : ambiance NPC, indices proactifs et bonus de score selon la progression.",
yaml: `blocks_studio_version: 2
nodes:
- id: g1
kind: phase
position: [40, 40]
next: g2
params:
id: enquete
duration_minutes: 20
- id: g2
kind: npcSetMood
position: [40, 130]
next: g3
params:
mood: mysterious
- id: g3
kind: proactiveHint
position: [40, 220]
next: g4
params:
interval_seconds: 180
hint_key: P1
- id: g4
kind: gmRule
position: [40, 310]
next: g5
params:
condition: "score < 3"
action: encourage
- id: g5
kind: scoreBonus
position: [40, 400]
params:
condition: "score > 5"
points: 10
`,
},
{
id: 'enigmes-coffre',
name: 'Énigmes → assemblage du coffre',
description: "Chaîne de trois énigmes (QR, morse, clavier) dont les fragments assemblent le code du coffre final.",
yaml: `blocks_studio_version: 2
nodes:
- id: p1
kind: puzzleQR
position: [40, 40]
next: p2
params:
id: P2_QR
codes: "ZACUS-2026"
fragment: A
- id: p2
kind: puzzleMorse
position: [40, 130]
next: p3
params:
id: P5_MORSE
message: SOS
fragment: B
- id: p3
kind: puzzleCoffre
position: [40, 220]
next: p4
params:
id: P7_COFFRE
length: 4
fragment: C
- id: p4
kind: codeAssembly
position: [40, 310]
next: p5
params:
length: 3
fragments: "A,B,C"
- id: p5
kind: coffreVerify
position: [40, 400]
params:
id: P7_COFFRE
`,
},
];
+127
View File
@@ -0,0 +1,127 @@
import { parseBlocksDocYaml, type BlocksNode } from '@zacus/shared';
import { type SceneDraft, type ScreenEffect } from './screenSpec.js';
/**
* Flow-graph model derived from the editor's live document (runtimeStore's
* `currentIr` / `pendingIr`).
*
* NB: despite the field name, that string is **not** a compiled Runtime 3 IR —
* it is the `blocks_studio_version: 2` document emitted by the Blockly editor
* (`serializeBlocksDoc`). A "step" is a `sceneStart` node; the chain that
* follows it (via `next`, until the next `sceneStart`) carries its master
* screen (`scene` block) and its outgoing transitions (`sceneGoto`,
* `sceneBranch`, `npcIntentMatch`). The entry step is the first `sceneStart`.
*/
export interface FlowNode {
id: string;
scene: SceneDraft;
}
export interface FlowEdge {
from: string;
to: string;
}
/** Parsed flow graph: the result of {@link parseIr}. */
export interface FlowGraphData {
nodes: FlowNode[];
edges: FlowEdge[];
entry: string | null;
}
const VALID_EFFECTS: readonly ScreenEffect[] = ['pulse', 'glitch', 'gyro', 'none'];
function toEffect(value: unknown): ScreenEffect {
return VALID_EFFECTS.includes(value as ScreenEffect) ? (value as ScreenEffect) : 'pulse';
}
function param(node: BlocksNode, key: string): string {
const v = node.params?.[key];
return v === undefined || v === null ? '' : String(v).trim();
}
/** Heads reachable from a node: its `next` plus any c-block slot heads. */
function chainHeads(node: BlocksNode): string[] {
const heads: string[] = [];
if (node.next) heads.push(node.next);
for (const head of Object.values(node.slots ?? {})) heads.push(head);
return heads;
}
/**
* Parse the editor document into flow nodes + edges. Invalid / empty input
* yields an empty graph. Edges only keep targets that resolve to a known step.
*/
export function parseIr(irJson: string | null): FlowGraphData {
if (!irJson) return { nodes: [], edges: [], entry: null };
let nodes: BlocksNode[];
try {
nodes = parseBlocksDocYaml(irJson).nodes;
} catch {
return { nodes: [], edges: [], entry: null };
}
const byId: Record<string, BlocksNode> = {};
for (const n of nodes) byId[n.id] = n;
// Steps are sceneStart nodes, keyed by their declared scene id.
const flowNodes: FlowNode[] = [];
const stepIds = new Set<string>();
for (const n of nodes) {
if (n.kind !== 'sceneStart') continue;
const id = param(n, 'id');
if (!id || stepIds.has(id)) continue;
stepIds.add(id);
flowNodes.push({ id, scene: { title: '', subtitle: '', symbol: '', effect: 'pulse' } });
}
const entry = flowNodes[0]?.id ?? null;
// Walk each step's chain (following next + slots, stopping at the next
// sceneStart) to collect its master screen and outgoing transitions.
const rawEdges: FlowEdge[] = [];
for (const start of nodes) {
if (start.kind !== 'sceneStart') continue;
const from = param(start, 'id');
if (!from) continue;
const node = flowNodes.find((fn) => fn.id === from);
const seen = new Set<string>([start.id]);
const queue = chainHeads(start);
while (queue.length > 0) {
const headId = queue.shift();
if (headId === undefined || seen.has(headId)) continue;
seen.add(headId);
const cur = byId[headId];
if (!cur || cur.kind === 'sceneStart') continue; // next step owns it
if (cur.kind === 'scene' && node) {
node.scene = {
title: param(cur, 'title'),
subtitle: param(cur, 'subtitle'),
symbol: param(cur, 'symbol'),
effect: toEffect(cur.params?.['effect']),
};
} else if (cur.kind === 'sceneGoto') {
const to = param(cur, 'target');
if (to) rawEdges.push({ from, to });
} else if (cur.kind === 'sceneBranch') {
for (const key of ['ifTrue', 'ifFalse']) {
const to = param(cur, key);
if (to) rawEdges.push({ from, to });
}
} else if (cur.kind === 'npcIntentMatch') {
const to = param(cur, 'then');
if (to) rawEdges.push({ from, to });
}
for (const h of chainHeads(cur)) queue.push(h);
}
}
// Keep only edges whose target resolves to a known step.
const edges = rawEdges.filter((e) => stepIds.has(e.to));
return { nodes: flowNodes, edges, entry };
}
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { parseBlocksDocYaml } from '@zacus/shared';
import { runtime3ToBlocksDoc } from './runtime3-import';
/** Convenience: convert and re-parse so we assert on the node graph. */
function convert(yaml: string) {
const { doc, warnings, stepCount } = runtime3ToBlocksDoc(yaml);
return { nodes: parseBlocksDocYaml(doc).nodes, warnings, stepCount };
}
describe('runtime3ToBlocksDoc', () => {
it('maps firmware.steps with structured transitions to sceneStart→on*→sceneEnd chains', () => {
const yaml = `
firmware:
initial_step: STEP_A
steps:
- step_id: STEP_A
transitions:
- event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_B
- step_id: STEP_B
transitions: []
`;
const { nodes, stepCount } = convert(yaml);
expect(stepCount).toBe(2);
const kinds = nodes.map((n) => n.kind);
// each step: sceneStart + (transitions) + sceneEnd
expect(kinds.filter((k) => k === 'sceneStart')).toHaveLength(2);
expect(kinds.filter((k) => k === 'sceneEnd')).toHaveLength(2);
const onSerial = nodes.find((n) => n.kind === 'onSerial');
expect(onSerial?.params).toMatchObject({ token: 'BTN_NEXT', target: 'B' });
// sceneStart id is stripped of the STEP_ prefix so it round-trips.
expect(nodes.find((n) => n.kind === 'sceneStart')?.params?.['id']).toBe('A');
});
it('emits an inline scene block and a sound puzzle block', () => {
const yaml = `
firmware:
steps:
- step_id: STEP_SON
scene:
title: PROTO U-SON
subtitle: transmission active
symbol: ALERT
effect: glitch
puzzle:
id: 1
type: sound
melody: [60, 62, 64]
tolerance: 1
fragment: "12"
transitions: []
`;
const { nodes } = convert(yaml);
const scene = nodes.find((n) => n.kind === 'scene');
expect(scene?.params).toMatchObject({
title: 'PROTO U-SON',
subtitle: 'transmission active',
symbol: 'ALERT',
effect: 'glitch',
});
const puzzle = nodes.find((n) => n.kind === 'puzzleSound');
expect(puzzle?.params).toMatchObject({ melody: '60, 62, 64', tolerance: 1, fragment: '12' });
});
it('joins a scene catalog (lvgl) by screen_scene_id', () => {
const yaml = `
scenes:
- scene_id: SCENE_X
lvgl:
title: TITRE X
subtitle: sous-titre
transition:
effect: pulse
firmware:
steps:
- step_id: STEP_X
screen_scene_id: SCENE_X
transitions: []
`;
const { nodes } = convert(yaml);
const scene = nodes.find((n) => n.kind === 'scene');
expect(scene?.params).toMatchObject({ title: 'TITRE X', subtitle: 'sous-titre', effect: 'pulse' });
});
it('parses compact-string transitions and routes a QR puzzle', () => {
const yaml = `
prompt_input:
steps_runtime_order:
- step_id: STEP_QR
screen_scene_id: SCENE_QR
puzzle:
id: 3
type: qr
codes: [WIN, ZAC-A1]
transitions:
- "unlock:UNLOCK_QR->STEP_FINAL"
- step_id: STEP_FINAL
transitions: []
`;
const { nodes, stepCount } = convert(yaml);
expect(stepCount).toBe(2);
expect(nodes.find((n) => n.kind === 'puzzleQR')?.params?.['codes']).toBe('WIN, ZAC-A1');
expect(nodes.find((n) => n.kind === 'onUnlock')?.params).toMatchObject({
source: 'UNLOCK_QR',
target: 'FINAL',
});
});
it('falls back to sceneGoto for non-firmware event types (timer/action)', () => {
const yaml = `
firmware:
steps:
- step_id: STEP_A
transitions:
- event_type: timer
event_name: WIN_DUE
target_step_id: STEP_B
- step_id: STEP_B
transitions: []
`;
const { nodes } = convert(yaml);
const goto = nodes.find((n) => n.kind === 'sceneGoto');
expect(goto?.params).toMatchObject({ target: 'B' });
});
it('reports unmappable features as warnings instead of crashing', () => {
const yaml = `
firmware:
steps:
- step_id: STEP_A
audio_pack_id: PACK_BOOT
narrative: "long story"
puzzle:
id: 2
type: led
pattern: "R1, LED"
transitions: []
`;
const { warnings } = convert(yaml);
expect(warnings.some((w) => w.includes('audio_pack_id'))).toBe(true);
expect(warnings.some((w) => w.includes('narrative'))).toBe(true);
// led puzzle has no faithful block → skipped with a warning.
expect(warnings.some((w) => w.toLowerCase().includes('led') || w.includes('no authoring block'))).toBe(true);
});
it('throws a clear error when no step list is present (e.g. a scene catalog file)', () => {
const yaml = `
scenes:
- scene_id: SCENE_ONLY
lvgl:
title: T
`;
expect(() => runtime3ToBlocksDoc(yaml)).toThrow(/aucune liste/);
});
});
@@ -0,0 +1,384 @@
/**
* runtime3-import — best-effort importer for the *canonical, human-authored*
* Runtime 3 scenarios (e.g. `game/scenarios/zacus_v2.yaml`,
* `scene_editor_all.yaml`, `scenario_reel_template.yaml`) into the Atelier
* Blockly editor.
*
* The editor natively understands only the flat `blocks_studio_version: 2`
* document (see blocks-doc.ts). Canonical scenarios use a completely different
* shape: a mapping with a list of *steps*, where each step has an id, an
* optional `scene` object {title, subtitle, symbol, effect}, an optional
* `puzzle` object {type: qr|sound|…}, and transitions expressed either as
* structured objects ({event_type, event_name, target_step_id}) or as compact
* strings (`serial:BTN_NEXT->STEP_X`).
*
* `runtime3ToBlocksDoc()` parses that human format and maps each step to a
* chain of blocks:
*
* sceneStart(id)
* → scene(title, subtitle, symbol, effect) [if a scene object]
* → puzzleQR/puzzleSound(...) [if a puzzle object]
* → sceneGoto / on{Button,AudioDone,Unlock,…}(...) [one per transition]
* → sceneEnd
*
* Anything that has no block equivalent (hints, npc dialogue, acts, timeboxes,
* led policies, fx timelines, audio packs, unknown puzzle types, …) is skipped
* cleanly and reported in `warnings`. The function never throws on a
* well-formed-but-unmappable scenario; it throws only when it cannot find any
* recognisable list of steps at all.
*
* The produced document is a valid `blocks_studio_version: 2` string that the
* existing importYamlToWorkspace() / the gateway compiler can consume.
*/
import { load } from 'js-yaml';
import { serializeBlocksDoc, type BlocksNode } from '@zacus/shared';
export interface Runtime3ImportResult {
/** A serialized `blocks_studio_version: 2` document. */
doc: string;
/** Best-effort mapping notes: skipped features, clamped values, etc. */
warnings: string[];
/** Number of canonical steps that produced a sceneStart chain. */
stepCount: number;
}
type Yaml = Record<string, unknown>;
const isObject = (v: unknown): v is Yaml =>
typeof v === 'object' && v !== null && !Array.isArray(v);
const asString = (v: unknown): string =>
v === null || v === undefined ? '' : String(v);
/**
* A canonical step id like `STEP_LA_DETECTOR` or scene id `SCENE_QR` carries a
* conventional prefix. Blockly's sceneStart `id` and the `target` of
* sceneGoto/on* blocks are bare names that the gateway re-prefixes with
* `STEP_`. Strip a leading STEP_/SCENE_ so ids round-trip to the same step.
*/
function bareId(raw: string): string {
const s = raw.trim();
return s.replace(/^STEP_/, '').replace(/^SCENE_/, '');
}
/** Real firmware event types understood by the gateway/EVENT_TYPES set. */
const KNOWN_EVENT_TYPES = new Set([
'button',
'serial',
'timer',
'audio_done',
'unlock',
'espnow',
'action',
]);
/**
* Locate the list of steps in a canonical scenario. Several human layouts ship
* the steps under different keys; probe them in order of specificity.
* `scenario_reel_template.yaml` nests them under `prompt_input`.
*/
function findSteps(doc: Yaml): { steps: Yaml[]; sourceKey: string } | null {
const candidates: Array<[string, unknown]> = [];
const push = (key: string, holder: unknown) => {
if (isObject(holder)) candidates.push([key, (holder as Yaml)[key]]);
};
// zacus_v2 style: firmware.steps
push('steps', doc['firmware']);
// template style: prompt_input.steps_runtime_order
push('steps_runtime_order', doc['prompt_input']);
// top-level fallbacks
candidates.push(['steps', doc['steps']]);
candidates.push(['steps_runtime_order', doc['steps_runtime_order']]);
candidates.push(['steps_narrative', doc['steps_narrative']]);
for (const [key, value] of candidates) {
if (Array.isArray(value) && value.some(isObject)) {
return { steps: value.filter(isObject) as Yaml[], sourceKey: key };
}
}
return null;
}
/**
* Build an index of scene catalog objects keyed by scene id. Canonical
* scenarios keep the visual scene definitions (title/subtitle/symbol/effect)
* separate from the runtime steps — either inline on the step (`scene: {…}`),
* under a top-level `scenes:` list (scene_editor_all, with the fields under
* `lvgl`), or under a `scene_screen_audio_catalog_all`. Steps reference them by
* `screen_scene_id` / `scene` (a string).
*/
function buildSceneCatalog(doc: Yaml): Map<string, Yaml> {
const out = new Map<string, Yaml>();
const lists: unknown[] = [doc['scenes']];
const promptInput = doc['prompt_input'];
if (isObject(promptInput)) lists.push(promptInput['scene_screen_audio_catalog_all']);
for (const list of lists) {
if (!Array.isArray(list)) continue;
for (const entry of list) {
if (!isObject(entry)) continue;
const id = asString(entry['scene_id'] ?? entry['id']).trim();
if (id) out.set(id, entry);
}
}
return out;
}
/** Extract {title, subtitle, symbol, effect} from a scene object or catalog
* entry. Catalog entries (scene_editor_all) nest them under `lvgl`. Returns
* null when nothing usable is present. */
function sceneParams(source: Yaml | undefined): Record<string, string> | null {
if (!source) return null;
const lvgl = isObject(source['lvgl']) ? (source['lvgl'] as Yaml) : source;
const title = asString(lvgl['title']).trim();
const subtitle = asString(lvgl['subtitle']).trim();
const symbol = asString(lvgl['symbol']).trim();
// effect can live on the scene object, its lvgl.transition, or lvgl.fx.
let effect = asString(source['effect'] ?? lvgl['effect']).trim();
if (!effect && isObject(lvgl['transition'])) {
effect = asString((lvgl['transition'] as Yaml)['effect']).trim();
}
const params: Record<string, string> = {};
if (title) params['title'] = title;
if (subtitle) params['subtitle'] = subtitle;
if (symbol) params['symbol'] = symbol;
// The scene block only accepts pulse/glitch/gyro/none; the compiler clamps
// anything else (with its own warning), so pass it through verbatim.
if (effect) params['effect'] = effect;
return Object.keys(params).length > 0 ? params : null;
}
/**
* Map a canonical puzzle object to a puzzle block node. Only `qr` and `sound`
* have a faithful authoring block today; other types are reported and skipped.
* Returns null + a warning for unmappable puzzles.
*/
function puzzleNode(
puzzle: Yaml,
id: string,
warnings: string[],
pos: [number, number],
): BlocksNode | null {
const rawType = asString(puzzle['type']).trim().toLowerCase();
const slot =
typeof puzzle['id'] === 'number'
? puzzle['id']
: Number(asString(puzzle['id'])) || undefined;
const fragment = asString(puzzle['fragment']).trim();
// The canonical `type` is a free-text descriptor in some files
// (e.g. "audio / stabilisation"). Match on substrings.
if (rawType.includes('qr') || rawType.includes('scan')) {
const codes = Array.isArray(puzzle['codes'])
? (puzzle['codes'] as unknown[]).map(asString).join(', ')
: asString(puzzle['qr_payload'] ?? puzzle['expected_payload'] ?? puzzle['codes']).trim();
if (!codes) {
warnings.push(`${id}: QR puzzle without codes/payload — block skipped`);
return null;
}
const params: Record<string, string | number> = { codes };
if (slot !== undefined) params['id'] = slot;
if (fragment) params['fragment'] = fragment;
return { id: `${id}__puzzle`, kind: 'puzzleQR', position: pos, params };
}
if (rawType.includes('sound') || rawType.includes('audio') || rawType.includes('son')) {
const melody = Array.isArray(puzzle['melody'])
? (puzzle['melody'] as unknown[]).map(asString).join(', ')
: asString(puzzle['melody']).trim();
if (!melody) {
warnings.push(
`${id}: sound puzzle "${rawType}" has no melody (notes) — block skipped`,
);
return null;
}
const params: Record<string, string | number> = { melody };
if (slot !== undefined) params['id'] = slot;
const tol = asString(puzzle['tolerance']).trim();
if (tol) params['tolerance'] = Number(tol) || 0;
if (fragment) params['fragment'] = fragment;
return { id: `${id}__puzzle`, kind: 'puzzleSound', position: pos, params };
}
warnings.push(
`${id}: puzzle type "${rawType || '(none)'}" has no authoring block — skipped`,
);
return null;
}
/**
* Parse a transition (object or compact string) into an event_type, raw target
* id and optional event_name. Returns null when no target can be determined.
*/
function parseTransition(
raw: unknown,
): { eventType: string; eventName: string; target: string } | null {
if (typeof raw === 'string') {
// Compact form: "<event_type>:<event_name>-><target_step_id>".
const arrowIdx = raw.indexOf('->');
if (arrowIdx < 0) return null;
const head = raw.slice(0, arrowIdx);
const target = raw.slice(arrowIdx + 2).trim();
if (!target) return null;
const colonIdx = head.indexOf(':');
const eventType = colonIdx >= 0 ? head.slice(0, colonIdx) : 'serial';
const eventName = colonIdx >= 0 ? head.slice(colonIdx + 1) : head;
return {
eventType: eventType.trim().toLowerCase(),
eventName: eventName.trim(),
target,
};
}
if (isObject(raw)) {
const target = asString(
raw['target_step_id'] ?? raw['target'] ?? raw['goto'] ?? raw['next'],
).trim();
if (!target) return null;
return {
eventType: asString(raw['event_type']).trim().toLowerCase() || 'serial',
eventName: asString(raw['event_name']).trim(),
target,
};
}
return null;
}
/**
* Map a parsed transition to the matching authoring block. Real firmware event
* types get the dedicated « Quand … → aller à » blocks; everything else falls
* back to sceneGoto (the compiler turns it into an `action:goto` transition).
*/
function transitionNode(
t: { eventType: string; eventName: string; target: string },
nodeId: string,
pos: [number, number],
): BlocksNode {
const target = bareId(t.target);
const base = { id: nodeId, position: pos } as const;
switch (t.eventType) {
case 'button':
return { ...base, kind: 'onButton', params: { button: t.eventName || 'NEXT', target } };
case 'audio_done':
return { ...base, kind: 'onAudioDone', params: { target } };
case 'unlock':
return { ...base, kind: 'onUnlock', params: { source: t.eventName || 'door', target } };
case 'serial':
return { ...base, kind: 'onSerial', params: { token: t.eventName || 'OK', target } };
case 'espnow':
return { ...base, kind: 'onEspnow', params: { command: t.eventName || 'ready', target } };
default:
// timer / action / unknown → plain goto.
return { ...base, kind: 'sceneGoto', params: { target } };
}
}
const STEP_DX = 360; // horizontal gap between step chains
const ROW_DY = 90; // vertical gap between blocks within a chain
export function runtime3ToBlocksDoc(yamlText: string): Runtime3ImportResult {
const parsed = load(yamlText);
if (!isObject(parsed)) {
throw new Error('Runtime 3 : le document YAML doit être un mapping en racine.');
}
const doc = parsed as Yaml;
const found = findSteps(doc);
if (!found) {
throw new Error(
"Runtime 3 : aucune liste d'étapes reconnue (firmware.steps, steps_runtime_order, steps…).",
);
}
const { steps, sourceKey } = found;
const warnings: string[] = [];
const sceneCatalog = buildSceneCatalog(doc);
// Some files keep the narrative scene<->step pairing separately; map step id
// → scene id so we can join even when the step lists only `screen_scene_id`.
const nodes: BlocksNode[] = [];
let stepCount = 0;
steps.forEach((step, index) => {
const rawStepId = asString(step['step_id'] ?? step['id'] ?? step['scene_id']).trim();
if (!rawStepId) {
warnings.push(`step #${index + 1}: no id (step_id/id) — skipped`);
return;
}
const sid = bareId(rawStepId);
const colX = index * STEP_DX + 40;
let row = 0;
const at = (): [number, number] => [colX, 40 + row++ * ROW_DY];
// Chain head: sceneStart(id).
const startNode: BlocksNode = {
id: `${sid}__start`,
kind: 'sceneStart',
position: at(),
params: { id: sid },
};
const chain: BlocksNode[] = [startNode];
// Scene object: inline `scene` (object), or joined from the catalog via the
// step's screen_scene_id / scene (string).
const inlineScene = isObject(step['scene']) ? (step['scene'] as Yaml) : undefined;
const sceneRef = asString(step['screen_scene_id'] ?? step['scene']).trim();
const catalogScene = sceneRef ? sceneCatalog.get(sceneRef) : undefined;
const sceneP = sceneParams(inlineScene) ?? sceneParams(catalogScene);
if (sceneP) {
chain.push({ id: `${sid}__scene`, kind: 'scene', position: at(), params: sceneP });
}
// Puzzle object: inline on the step.
if (isObject(step['puzzle'])) {
const pNode = puzzleNode(step['puzzle'] as Yaml, sid, warnings, at());
if (pNode) chain.push(pNode);
}
// npc / hints / acts / timeboxes / audio packs have no block — report once.
for (const skipKey of ['npc', 'hints', 'hint', 'narrative', 'act', 'timebox_minutes', 'audio_pack_id', 'apps']) {
if (step[skipKey] !== undefined && step[skipKey] !== '' &&
!(Array.isArray(step[skipKey]) && (step[skipKey] as unknown[]).length === 0)) {
warnings.push(`${sid}: "${skipKey}" not expressible as a block — skipped`);
}
}
// Transitions → goto / on* blocks.
const rawTransitions = Array.isArray(step['transitions']) ? step['transitions'] : [];
rawTransitions.forEach((rt, ti) => {
const t = parseTransition(rt);
if (!t) {
warnings.push(`${sid}: transition #${ti + 1} unparseable — skipped`);
return;
}
if (t.eventType && !KNOWN_EVENT_TYPES.has(t.eventType)) {
warnings.push(
`${sid}: transition event_type "${t.eventType}" unknown — emitted as goto`,
);
}
chain.push(transitionNode(t, `${sid}__t${ti}`, at()));
});
// Chain tail: sceneEnd (a cap block).
chain.push({ id: `${sid}__end`, kind: 'sceneEnd', position: at() });
// Wire `next` pointers down the chain.
for (let i = 0; i < chain.length - 1; i++) {
const cur = chain[i];
const nxt = chain[i + 1];
if (cur && nxt) cur.next = nxt.id;
}
nodes.push(...chain);
stepCount++;
});
if (stepCount === 0) {
throw new Error(
`Runtime 3 : la liste "${sourceKey}" ne contient aucune étape importable.`,
);
}
warnings.unshift(
`Importé depuis "${sourceKey}" — ${stepCount} étape(s) converties en chaînes de blocs.`,
);
return { doc: serializeBlocksDoc(nodes), warnings, stepCount };
}
@@ -0,0 +1,188 @@
import type { BlocksNode } from '@zacus/shared';
/**
* ScenePlayer — lightweight blocks_studio v2 walker for the 3D stage sim.
*
* Plays the scene graph the way the firmware's Runtime 3 engine would, at
* visualization fidelity: « Zacus dit » surfaces as the NPC bubble (+ Web
* Speech), timers wait, gotos/branches jump between scenes, hardware
* actions are narrated to the log. This is a *simulation aid*, not the
* deploy path — the authoritative compiler stays blocks_to_runtime3.py.
*/
export interface ScenePlayerCallbacks {
/** « Zacus dit » — text plus the voice fields carried by the block. */
onSay: (
text: string,
opts: { voice?: string | undefined; emotion?: string | undefined; rate?: number | undefined },
) => void;
/** One narration line per non-voice action (hardware, ESP-NOW, …). */
onLog: (line: string) => void;
/** Entered a scene (sceneStart id, or synthetic chain head). */
onSceneChange: (sceneId: string) => void;
/** Walk finished (sceneEnd, dangling chain or safety cap). */
onEnd: (reason: string) => void;
}
const MAX_VISITED_BLOCKS = 500; // runaway-goto safety cap
const DEFAULT_WAIT_LOG_MS = 250; // narration pacing for instant actions
export class ScenePlayer {
private byId = new Map<string, BlocksNode>();
private sceneHeads = new Map<string, BlocksNode>();
private timer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
private visited = 0;
constructor(
nodes: readonly BlocksNode[],
private cb: ScenePlayerCallbacks,
) {
for (const n of nodes) this.byId.set(n.id, n);
for (const n of nodes) {
if (n.kind !== 'sceneStart') continue;
const sceneId = String(n.params?.['id'] ?? '').trim();
if (sceneId && !this.sceneHeads.has(sceneId)) this.sceneHeads.set(sceneId, n);
}
}
/** Start at the first sceneStart (document order). No-op when none. */
play(): void {
const entry = [...this.byId.values()].find((n) => n.kind === 'sceneStart');
if (!entry) {
this.cb.onEnd('aucune scène');
return;
}
this.enter(entry);
}
stop(): void {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
private enter(start: BlocksNode): void {
const sceneId = String(start.params?.['id'] ?? '').trim() || start.id;
this.cb.onSceneChange(sceneId);
this.step(this.next(start));
}
private gotoScene(target: string, from: string): void {
const head = this.sceneHeads.get(target.trim());
if (!head) {
this.cb.onLog(`${from} → scène inconnue « ${target} » — arrêt`);
this.cb.onEnd('cible inconnue');
return;
}
this.enter(head);
}
private next(node: BlocksNode): BlocksNode | null {
return node.next ? (this.byId.get(node.next) ?? null) : null;
}
private after(ms: number, fn: () => void): void {
this.timer = setTimeout(() => {
if (!this.stopped) fn();
}, ms);
}
private step(node: BlocksNode | null): void {
if (this.stopped) return;
if (!node) {
this.cb.onEnd('fin de pile');
return;
}
if (++this.visited > MAX_VISITED_BLOCKS) {
this.cb.onLog('⚠ boucle de scènes détectée — arrêt de la simulation');
this.cb.onEnd('boucle');
return;
}
const p = node.params ?? {};
switch (node.kind) {
case 'sceneEnd':
this.cb.onEnd('⏹ fin de scène');
return;
case 'sceneStart': // mid-chain start: treat as a new scene head
this.enter(node);
return;
case 'sceneGoto':
this.gotoScene(String(p['target'] ?? ''), 'Aller à');
return;
case 'sceneBranch': {
// No expression evaluator in the sim — narrate and take « alors ».
this.cb.onLog(`Branche si « ${String(p['condition'] ?? '')} » — sim: alors`);
this.gotoScene(String(p['ifTrue'] ?? ''), 'Branche');
return;
}
case 'npcIntentMatch': {
this.cb.onLog(`Si intent = « ${String(p['intent'] ?? '')} » — sim: suppose oui`);
this.gotoScene(String(p['then'] ?? ''), 'Intent');
return;
}
case 'npcSay': {
const text = String(p['text'] ?? '');
const rate = Number(p['rate'] ?? 1) || 1;
this.cb.onSay(text, {
voice: String(p['voice'] ?? '') || undefined,
emotion: String(p['emotion'] ?? '') || undefined,
rate,
});
// Rough speaking time: ~15 chars/s adjusted by rate, min 1 s.
const ms = Math.max(1000, ((text.length / 15) * 1000) / rate);
this.after(ms, () => this.step(this.next(node)));
return;
}
case 'npcWaitResponse': {
const s = Number(p['timeout_s'] ?? 10) || 10;
this.cb.onLog(`Attente réponse joueur (${s} s) — sim: 1 s`);
this.after(1000, () => this.step(this.next(node)));
return;
}
case 'logicTimer': {
const s = Number(p['seconds'] ?? 5) || 0;
this.cb.onLog(`Attendre ${s} s`);
this.after(Math.min(s, 5) * 1000, () => this.step(this.next(node)));
return;
}
case 'logicIf': {
// Narrate and walk the « alors » slot inline, then continue.
this.cb.onLog(`si « ${String(p['condition'] ?? '')} » — sim: alors`);
const bodyHead = node.slots?.['body'] ? this.byId.get(node.slots['body']) : null;
if (bodyHead) this.walkSlotChain(bodyHead);
this.after(DEFAULT_WAIT_LOG_MS, () => this.step(this.next(node)));
return;
}
default: {
// Hardware / ESP-NOW / M5 / PLIP and friends: narrate.
const detail = Object.entries(p)
.map(([k, v]) => `${k}=${String(v)}`)
.join(' ');
this.cb.onLog(`[${node.kind}] ${detail}`.trim());
this.after(DEFAULT_WAIT_LOG_MS, () => this.step(this.next(node)));
return;
}
}
}
/** Narrate a logicIf slot chain synchronously (plain actions only). */
private walkSlotChain(head: BlocksNode): void {
let cur: BlocksNode | null = head;
let guard = 0;
while (cur && guard++ < 50) {
const p: Record<string, string | number> = cur.params ?? {};
if (cur.kind === 'npcSay') {
this.cb.onSay(String(p['text'] ?? ''), {
voice: String(p['voice'] ?? '') || undefined,
emotion: String(p['emotion'] ?? '') || undefined,
rate: Number(p['rate'] ?? 1) || 1,
});
} else {
this.cb.onLog(` ↳ [${cur.kind}]`);
}
cur = this.next(cur);
}
}
}
@@ -0,0 +1,66 @@
/**
* Faithful mirror of the firmware LCD scene model (P3a of the asset authoring
* plan). Values come straight from ESP32_ZACUS display_ui.cpp (ST7796 480×320
* landscape, Workbench palette) and the gateway-side scene contract in
* tools/scenario/runtime3_common.py::_validate_step_scene. Keep the two in
* sync — the char limits and effect enum are enforced at compile time.
*/
export type ScreenEffect = 'pulse' | 'glitch' | 'gyro' | 'none';
export const EFFECTS: { value: ScreenEffect; label: string }[] = [
{ value: 'pulse', label: 'Pulse (respiration)' },
{ value: 'glitch', label: 'Glitch' },
{ value: 'gyro', label: 'Gyro (anneau)' },
{ value: 'none', label: 'Aucun' },
];
/** Panel geometry, colours and per-field fonts/limits — mirror of display_ui.cpp. */
export const SCREEN = {
width: 480,
height: 320,
bg: '#0055AA', // COL_BG — Workbench blue
title: { max: 47, color: '#FFFFFF', font: "'Orbitron','Arial Narrow',sans-serif" }, // COL_VALUE / Orbitron 40
symbol: { max: 15, color: '#FF8800', font: "'Orbitron','Arial Narrow',sans-serif" }, // COL_CODE / Orbitron 40
subtitle: { max: 63, color: '#AAAAAA', font: "'IBM Plex Mono',monospace" }, // COL_LABEL / IBM Plex Mono 18
} as const;
export interface SceneDraft {
title: string;
subtitle: string;
symbol: string;
effect: ScreenEffect;
}
export const EMPTY_SCENE: SceneDraft = { title: '', subtitle: '', symbol: '', effect: 'pulse' };
const FIELD_MAX: Record<'title' | 'subtitle' | 'symbol', number> = {
title: SCREEN.title.max,
subtitle: SCREEN.subtitle.max,
symbol: SCREEN.symbol.max,
};
/** Per-field over-limit check (matches the strict gateway validator). */
export function overLimit(field: 'title' | 'subtitle' | 'symbol', value: string): boolean {
return value.length > FIELD_MAX[field];
}
function yamlString(value: string): string {
// Quote conservatively — scene text routinely carries accents, colons and
// apostrophes. Double-quote and escape backslash/quote.
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
/**
* Render the `scene:` YAML block an author pastes onto a scenario step.
* Empty title/subtitle/symbol are omitted; effect is always written.
*/
export function sceneToYaml(s: SceneDraft, indent = 4): string {
const pad = ' '.repeat(indent);
const inner = ' '.repeat(indent + 2);
const lines = [`${pad}scene:`];
if (s.title.trim()) lines.push(`${inner}title: ${yamlString(s.title.trim())}`);
if (s.subtitle.trim()) lines.push(`${inner}subtitle: ${yamlString(s.subtitle.trim())}`);
if (s.symbol.trim()) lines.push(`${inner}symbol: ${yamlString(s.symbol.trim())}`);
lines.push(`${inner}effect: ${s.effect}`);
return lines.join('\n');
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Client for the gateway's public share endpoints (`POST /v1/share`,
* `GET /v1/share/{id}`). These are token-free by design so a player opening a
* shared link — who has no bearer token — can still load the scenario.
*
* Gateway base URL resolves from `VITE_GATEWAY_BASE_URL` (same env as useDeploy),
* defaulting to the LAN gateway on :8400.
*/
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
function gatewayBaseUrl(): string {
// Static `import.meta.env.VITE_*` access so Vite inlines the value at build
// time (an aliased / optional-chained read is NOT statically replaced).
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
return base.replace(/\/+$/, '');
}
/** Upload the scenario YAML, returning its short share id. */
export async function createShare(yaml: string): Promise<string> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yaml }),
});
if (!resp.ok) {
throw new Error(`partage refusé (HTTP ${resp.status})`);
}
const data = (await resp.json()) as { id: string };
return data.id;
}
/** Resolve a share id back to its scenario YAML. */
export async function fetchShare(id: string): Promise<string> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/share/${encodeURIComponent(id)}`);
if (!resp.ok) {
throw new Error(`lien introuvable (HTTP ${resp.status})`);
}
const data = (await resp.json()) as { yaml: string };
return data.yaml;
}
/** Build the shareable URL for an id, anchored on the current Atelier path. */
export function shareLinkFor(id: string): string {
return `${window.location.origin}${window.location.pathname}?s=${id}`;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Client for the gateway's asset staging endpoint (P4/P5): pushes the staged
* assets to a board's microSD via POST /v1/assets/stage. Token-protected like
* the flash endpoints (VITE_GATEWAY_TOKEN), so it carries the bearer header.
*/
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
function gatewayBaseUrl(): string {
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
return base.replace(/\/+$/, '');
}
function authHeaders(): Record<string, string> {
const token = import.meta.env.VITE_GATEWAY_TOKEN;
return {
'Content-Type': 'application/json',
Accept: 'application/json',
...(token && token.trim() ? { Authorization: `Bearer ${token}` } : {}),
};
}
export interface StageBoard {
name: string;
label: string;
ip: string | null;
}
export interface StageStep {
asset: string;
status: 'ok' | 'warn' | 'skip' | 'error';
detail: string;
}
export interface StageResult {
board: string;
strategy_used: string;
ok: boolean;
base_path: string;
steps: StageStep[];
manifest: string[];
}
/** Boards declared in the gateway's boards.yaml (name + reachability). */
export async function fetchBoards(): Promise<StageBoard[]> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/flash/boards`, { headers: authHeaders() });
if (!resp.ok) throw new Error(`liste des cartes indisponible (HTTP ${resp.status})`);
const raw = (await resp.json()) as { name: string; label: string; ip: string | null }[];
return raw.map((b) => ({ name: b.name, label: b.label, ip: b.ip }));
}
/** Stage the generated assets onto `board`'s SD (hot push or cold host bundle). */
export async function stageAssets(
board: string,
opts: { keys?: string[]; strategy?: 'auto' | 'hot' | 'cold' } = {},
): Promise<StageResult> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/stage`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ board, keys: opts.keys ?? [], strategy: opts.strategy ?? 'auto' }),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`staging refusé (HTTP ${resp.status})${detail ? `${detail.slice(0, 140)}` : ''}`);
}
return (await resp.json()) as StageResult;
}
/** Response of the D2 "arm a step" endpoint (POST /v1/step/{board}). */
export interface ArmStepResult {
board: string;
route: string;
ok: boolean;
status: number;
armed: boolean;
detail: string;
}
/** Response of the D3 "play a media cue" endpoint (POST /v1/media/play). */
export interface PlayMediaResult {
board: string;
route: string;
ok: boolean;
status: number;
detail: string;
}
/** D2 — arm a runtime step on `board` (gateway forwards to the live hardware). */
export async function armStep(board: string, stepId: string): Promise<ArmStepResult> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/step/${encodeURIComponent(board)}`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ step_id: stepId }),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`armement refusé (HTTP ${resp.status})${detail ? `${detail.slice(0, 140)}` : ''}`);
}
return (await resp.json()) as ArmStepResult;
}
/** D3 — play a media cue on `board`; the gateway resolves `path` to an absolute /sdcard path. */
export async function playMedia(board: string, path: string): Promise<PlayMediaResult> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/media/play`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ board, path }),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`lecture refusée (HTTP ${resp.status})${detail ? `${detail.slice(0, 140)}` : ''}`);
}
return (await resp.json()) as PlayMediaResult;
}
@@ -0,0 +1,35 @@
/**
* Client for the gateway's text-asset generation endpoint
* (`POST /v1/assets/text/generate`), which proxies the ailiance LLM gateway.
* Token-free, same-origin in prod (zacus.saillant.cc/api). P1 of the asset
* authoring plan: generate in-voice NPC lines, hints and screen text.
*/
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
function gatewayBaseUrl(): string {
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
return base.replace(/\/+$/, '');
}
export type TextGenKind = 'npc_reply' | 'hint' | 'scene_text' | 'freeform';
export interface TextGenResult {
kind: string;
text: string;
model?: string;
}
/** Generate a text asset of `kind` from the author `prompt`. */
export async function generateText(kind: TextGenKind, prompt: string): Promise<TextGenResult> {
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/text/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, prompt }),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`génération refusée (HTTP ${resp.status})${detail ? `${detail.slice(0, 120)}` : ''}`);
}
return (await resp.json()) as TextGenResult;
}
@@ -0,0 +1,57 @@
import { useEffect } from 'react';
import type * as Blockly from 'blockly';
import { useWorkspaceStore } from '../stores/workspaceStore.js';
/**
* On boot, if the URL carries `?s=<id>`, fetch the shared scenario from the
* gateway and import it into the Blockly workspace as soon as it mounts. The
* `?s=` param is then dropped from the address bar so a later reload (or the
* user's own edits) don't get clobbered by a re-import.
*/
export function useShareLink(): void {
useEffect(() => {
const id = new URLSearchParams(window.location.search).get('s');
if (!id) return;
let cancelled = false;
let unsubscribe: (() => void) | undefined;
const importInto = async (ws: Blockly.WorkspaceSvg, yaml: string) => {
const { importYamlToWorkspace } = await import('./yaml-import.js');
importYamlToWorkspace(ws, yaml);
const url = new URL(window.location.href);
url.searchParams.delete('s');
window.history.replaceState({}, '', url);
};
void (async () => {
let yaml: string;
try {
const { fetchShare } = await import('./share.js');
yaml = await fetchShare(id);
} catch (err) {
console.error('Atelier: échec du chargement du lien partagé', err);
return;
}
if (cancelled) return;
const current = useWorkspaceStore.getState().workspace;
if (current) {
void importInto(current, yaml);
return;
}
// Workspace not mounted yet — import once it appears.
unsubscribe = useWorkspaceStore.subscribe((state) => {
if (state.workspace && !cancelled) {
unsubscribe?.();
void importInto(state.workspace, yaml);
}
});
})();
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
}
+57 -50
View File
@@ -1,4 +1,4 @@
import type { ScenarioYaml } from '@zacus/shared';
import type { BlocksNode } from '@zacus/shared';
export interface ValidationResult {
valid: boolean;
@@ -8,76 +8,83 @@ export interface ValidationResult {
}
/**
* Validate a parsed ScenarioYaml against 6 rules derived from the Zacus V3 spec.
* Returns a ValidationResult with errors, warnings, and a human-readable summary.
* Validate blocks_studio v2 nodes against the same rules the gateway
* compiler (blocks_to_runtime3.py) enforces or warns about — surfaced in
* the editor before the user ever hits « Déployer ».
*/
export function validateScenario(scenario: ScenarioYaml): ValidationResult {
export function validateBlocksNodes(nodes: readonly BlocksNode[]): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Rule 1: All puzzle code_digits must sum to exactly 8 total digits
const totalDigits = scenario.puzzles.reduce((sum, p) => {
const count = (p.code_digits?.length ?? 0) + (p.code_digit != null ? 1 : 0);
return sum + count;
}, 0);
if (totalDigits !== 8) {
errors.push(`Les digits de code totalisent ${totalDigits} chiffre(s) — attendu 8`);
if (nodes.length === 0) {
return {
valid: true,
errors,
warnings: ['Espace de travail vide'],
summary: 'Espace de travail vide',
};
}
// Rule 2: Every puzzle must be referenced in at least one phase
const referencedPuzzles = new Set(
scenario.phases.flatMap((ph) => ph.puzzles.map((p) => p.puzzle_ref)),
);
for (const puzzle of scenario.puzzles) {
if (!referencedPuzzles.has(puzzle.id)) {
errors.push(`Puzzle ${puzzle.id} n'est référencé dans aucune phase`);
// Scene inventory.
const sceneStarts = nodes.filter((n) => n.kind === 'sceneStart');
const sceneIds = new Set<string>();
for (const s of sceneStarts) {
const id = String(s.params?.['id'] ?? '').trim();
if (!id) {
warnings.push('Un « ▶ Début de scène » a un id vide (step anonyme généré)');
continue;
}
if (sceneIds.has(id)) errors.push(`Id de scène dupliqué : « ${id} »`);
sceneIds.add(id);
}
// Rule 3: PROFILING phase must have exactly 2 puzzles (one TECH, one BOTH)
const profilingPhase = scenario.phases.find((ph) => ph.type === 'PROFILING');
if (!profilingPhase) {
errors.push('Aucune phase PROFILING définie');
} else {
if (profilingPhase.puzzles.length !== 2) {
errors.push(
`Phase PROFILING: ${profilingPhase.puzzles.length} puzzle(s) — attendu 2`,
// Rule 1: at least one entry point.
if (sceneStarts.length === 0) {
errors.push("Aucun bloc « ▶ Début de scène » — le scénario n'a pas de point d'entrée");
}
// Rule 2: every root chain should start with a sceneStart (the compiler
// emits a synthetic step otherwise).
const referenced = new Set<string>();
for (const n of nodes) {
if (n.next) referenced.add(n.next);
for (const head of Object.values(n.slots ?? {})) referenced.add(head);
}
for (const n of nodes) {
if (referenced.has(n.id)) continue; // not a root
if (n.kind !== 'sceneStart') {
warnings.push(
`La pile commençant par « ${n.kind} » ne démarre pas par un Début de scène (step synthétique)`,
);
}
const filters = profilingPhase.puzzles.map((p) => p.profile_filter);
if (!filters.includes('TECH')) warnings.push('Phase PROFILING: aucun puzzle TECH');
if (!filters.includes('BOTH')) warnings.push('Phase PROFILING: aucun puzzle BOTH');
}
// Rule 4: CLIMAX phase must exist and contain P7_COFFRE
const climaxPhase = scenario.phases.find((ph) => ph.type === 'CLIMAX');
if (!climaxPhase) {
errors.push('Aucune phase CLIMAX définie');
} else {
const hasCoffre = climaxPhase.puzzles.some((p) => p.puzzle_ref === 'P7_COFFRE');
if (!hasCoffre) {
errors.push('Phase CLIMAX: P7_COFFRE (Coffre Final) absent');
// Rule 3: navigation targets should reference known scene ids.
const checkTarget = (kind: string, value: unknown) => {
const target = String(value ?? '').trim();
if (target && sceneIds.size > 0 && !sceneIds.has(target)) {
warnings.push(`« ${kind} » pointe vers une scène inconnue : « ${target} »`);
}
};
for (const n of nodes) {
if (n.kind === 'sceneGoto') checkTarget('Aller à', n.params?.['target']);
if (n.kind === 'sceneBranch') {
checkTarget('Branche (alors)', n.params?.['ifTrue']);
checkTarget('Branche (sinon)', n.params?.['ifFalse']);
}
if (n.kind === 'npcIntentMatch') checkTarget('Si intent', n.params?.['then']);
}
// Rule 5: Duration target must be configured
if (!scenario.duration.target_minutes || scenario.duration.target_minutes <= 0) {
errors.push('Durée cible non configurée ou invalide');
}
// Rule 6: Scenario must have at least 3 phases (PROFILING, ADAPTIVE, CLIMAX)
const requiredPhases = ['PROFILING', 'ADAPTIVE', 'CLIMAX'] as const;
for (const required of requiredPhases) {
if (!scenario.phases.some((ph) => ph.type === required)) {
errors.push(`Phase ${required} manquante`);
// Rule 4: « Zacus dit » should not be empty (TTS of nothing).
for (const n of nodes) {
if (n.kind === 'npcSay' && !String(n.params?.['text'] ?? '').trim()) {
warnings.push('Un « Zacus dit » est vide — la voix ne dira rien');
}
}
const valid = errors.length === 0;
const puzzleCount = scenario.puzzles.length;
const durationMin = scenario.duration.target_minutes;
const summary = valid
? `Scénario valide — ${puzzleCount} puzzles, ~${durationMin} min`
? `Scénario valide — ${sceneStarts.length} scène(s), ${nodes.length} bloc(s)`
: `${errors.length} erreur(s) à corriger`;
return { valid, errors, warnings, summary };
+108 -36
View File
@@ -1,42 +1,114 @@
import * as Blockly from 'blockly/core';
import { javascriptGenerator } from 'blockly/javascript';
import { parseScenarioYaml } from '@zacus/shared';
import type * as Blockly from 'blockly/core';
import { serializeBlocksDoc, serializeReporter, type BlocksNode } from '@zacus/shared';
import { ZACUS_FIELDS_BY_KIND } from '@zacus/shared/blockly';
const PREFIX = 'zacus_';
// Reporter blocks have an output and no statement connections. They carry the
// value-input operands that compile to an expression string. Statement blocks
// that accept a branched condition reporter (logicIf/loopWhile/sceneBranch)
// expose a "condition" value input alongside a text field of repli.
const REPORTER_KINDS = new Set([
'opCompare', 'opBool', 'opNot', 'opArith',
'valVar', 'valScore', 'valNumber', 'valText',
]);
// For each block type, the value inputs whose branched reporter we snapshot
// into node.inputs (so the gateway can resolve them to an expression string).
const VALUE_INPUTS_BY_TYPE: Record<string, readonly string[]> = {
zacus_opCompare: ['A', 'B'],
zacus_opBool: ['A', 'B'],
zacus_opNot: ['A'],
zacus_opArith: ['A', 'B'],
zacus_logicIf: ['condition'],
zacus_loopWhile: ['condition'],
zacus_sceneBranch: ['condition'],
// Vague 2 — règles game-master : condition branchée (reporter) ou texte.
zacus_gmRule: ['condition'],
zacus_scoreBonus: ['condition'],
};
/**
* Export the current Blockly workspace to a zacus_v3_complete.yaml string.
* Runs all block generators and wraps the output in a minimal YAML skeleton,
* then validates by parsing. Throws if invalid.
* Snapshot the workspace as flat blocks_studio v2 nodes: one node per
* zacus_* block, `next` pointers from the stack connections, `slots` from
* the statement inputs (logicIf "alors"/"sinon", loops "faire"), `inputs`
* from value inputs (reporter operands). Kinds drop the zacus_ prefix to
* match the gateway compiler and the hub Studio.
*
* Backward-compat: for blocks with a branched condition reporter, we ALSO
* resolve it into the `condition` text param so consumers reading only the
* string (legacy firmware, scenario engine) keep working. If no reporter is
* branched, the typed text field is preserved untouched.
*/
export function exportWorkspaceToYaml(workspace: Blockly.Workspace): string {
const fragments = javascriptGenerator.workspaceToCode(workspace);
const skeleton = buildYamlSkeleton(fragments);
// Validate by parsing (throws if schema is invalid)
parseScenarioYaml(skeleton);
return skeleton;
export function workspaceToBlocksNodes(workspace: Blockly.Workspace): BlocksNode[] {
const nodes: BlocksNode[] = [];
const all = workspace.getAllBlocks(false).filter(
(b) => b.type.startsWith(PREFIX) && !b.isInsertionMarker(),
);
for (const block of all) {
const kind = block.type.slice(PREFIX.length);
const xy = block.getRelativeToSurfaceXY();
const node: BlocksNode = {
id: block.id,
kind,
position: [xy.x, xy.y],
};
// Reporters are wired through value inputs, never through next/previous.
if (!REPORTER_KINDS.has(kind)) {
const next = block.getNextBlock();
if (next) node.next = next.id;
}
const fieldNames = ZACUS_FIELDS_BY_KIND[block.type] ?? [];
const params: Record<string, string | number> = {};
for (const name of fieldNames) {
const value = block.getFieldValue(name) as string | number | null;
if (value !== null && value !== undefined) params[name] = value;
}
// Statement inputs (c-block bodies / else / loop bodies).
const slots: Record<string, string> = {};
for (const slotName of ['body', 'else']) {
const head = block.getInputTargetBlock(slotName);
if (head) slots[slotName] = head.id;
}
if (Object.keys(slots).length > 0) node.slots = slots;
// Value inputs (reporter operands + branched conditions).
const inputs: Record<string, string> = {};
for (const inName of VALUE_INPUTS_BY_TYPE[block.type] ?? []) {
const child = block.getInputTargetBlock(inName);
if (child) inputs[inName] = child.id;
}
if (Object.keys(inputs).length > 0) node.inputs = inputs;
if (Object.keys(params).length > 0) node.params = params;
nodes.push(node);
}
// Resolve branched condition reporters into the condition text param so the
// gateway and string-only consumers always see a usable expression string.
const byId: Record<string, BlocksNode> = {};
for (const n of nodes) byId[n.id] = n;
for (const n of nodes) {
const condChild = n.inputs?.['condition'];
if (!condChild) continue;
const expr = serializeReporter(condChild, byId);
if (!expr) continue;
const paramKey = n.kind === 'loopWhile' ? 'condition_text' : 'condition';
n.params = { ...(n.params ?? {}), [paramKey]: expr };
}
return nodes;
}
function buildYamlSkeleton(generatedFragments: string): string {
return `id: zacus_v3_complete
version: "3.1"
title: "Le Mystère du Professeur Zacus — V3 Complete"
duration:
target_minutes: 60
min_minutes: 30
max_minutes: 90
profiling_minutes: 10
climax_minutes: 10
outro_minutes: 5
hardware:
puzzles: []
scoring:
base_score: 1000
time_penalty_per_minute: 10
hint_penalty: 50
bonus_fast_completion: 200
npc:
adaptive_rules: []
phases: []
puzzles:
${generatedFragments}
`;
/**
* Export the current workspace to a blocks_studio_version: 2 YAML string —
* the format the gateway compiles to Runtime 3 IR (POST /v1/flash) and the
* zacus-hub native Studio reads/writes.
*/
export function exportWorkspaceToYaml(workspace: Blockly.Workspace): string {
return serializeBlocksDoc(workspaceToBlocksNodes(workspace));
}
+95 -103
View File
@@ -1,121 +1,113 @@
import * as Blockly from 'blockly/core';
import { parseScenarioYaml } from '@zacus/shared';
import type { PuzzleConfig } from '@zacus/shared';
import { parseBlocksDocYaml } from '@zacus/shared';
import { load } from 'js-yaml';
import { runtime3ToBlocksDoc } from './runtime3-import';
const PREFIX = 'zacus_';
/**
* Import a zacus_v3_complete.yaml string into the Blockly workspace.
* Clears the existing workspace and recreates all blocks from the scenario.
* Detect whether a YAML document is already in the native
* `blocks_studio_version: 2` format. Anything else (a canonical human-authored
* Runtime 3 scenario) is routed through runtime3ToBlocksDoc() first.
*/
export function importYamlToWorkspace(workspace: Blockly.Workspace, yaml: string): void {
const scenario = parseScenarioYaml(yaml);
function isBlocksStudioV2(yaml: string): boolean {
try {
const doc = load(yaml) as Record<string, unknown> | null;
return !!doc && typeof doc === 'object' && doc['blocks_studio_version'] === 2;
} catch {
return false;
}
}
export interface YamlImportResult {
/** Blocks created in the workspace. */
created: number;
/** "blocks" when the source was already v2, "runtime3" when converted. */
source: 'blocks' | 'runtime3';
/** Best-effort conversion notes (empty for native v2 documents). */
warnings: string[];
}
/**
* Import any supported scenario YAML into the workspace, auto-detecting the
* format. Native `blocks_studio_version: 2` documents go straight through;
* canonical human Runtime 3 scenarios are converted best-effort first, and the
* conversion warnings are surfaced to the caller.
*/
export function importScenarioToWorkspace(
workspace: Blockly.Workspace,
yaml: string,
): YamlImportResult {
if (isBlocksStudioV2(yaml)) {
return { created: importYamlToWorkspace(workspace, yaml), source: 'blocks', warnings: [] };
}
const { doc, warnings } = runtime3ToBlocksDoc(yaml);
return { created: importYamlToWorkspace(workspace, doc), source: 'runtime3', warnings };
}
/**
* Import a blocks_studio_version: 2 YAML document (Atelier export or
* zacus-hub Studio save) into the workspace. Clears existing content,
* recreates every known zacus_* block, restores fields, stack (`next`)
* and logicIf slot connections, then positions root blocks.
*
* Returns the number of blocks created; unknown kinds are skipped.
*/
export function importYamlToWorkspace(workspace: Blockly.Workspace, yaml: string): number {
const doc = parseBlocksDocYaml(yaml);
workspace.clear();
let yOffset = 20;
const created = new Map<string, Blockly.Block>();
// Create phase_container blocks
for (const phase of scenario.phases) {
const phaseBlock = workspace.newBlock('phase_container') as Blockly.Block;
phaseBlock.setFieldValue(phase.type, 'PHASE_TYPE');
initBlock(phaseBlock, 20, yOffset);
yOffset += 120;
// Nested puzzle_selector blocks
let prevBlock: Blockly.Block | null = null;
for (const p of phase.puzzles) {
const selBlock = workspace.newBlock('puzzle_selector') as Blockly.Block;
selBlock.setFieldValue(p.puzzle_ref, 'PUZZLE_REF');
selBlock.setFieldValue(p.profile_filter, 'PROFILE_FILTER');
initBlock(selBlock, 0, 0);
if (prevBlock === null) {
phaseBlock.getInput('PUZZLES')?.connection?.connect(selBlock.previousConnection!);
} else {
prevBlock.nextConnection?.connect(selBlock.previousConnection!);
// Pass 1 — create blocks and restore fields.
for (const node of doc.nodes) {
const type = PREFIX + node.kind;
if (!(type in Blockly.Blocks)) continue; // unknown kind — skip
const block = workspace.newBlock(type, node.id);
for (const [name, value] of Object.entries(node.params ?? {})) {
try {
block.setFieldValue(String(value), name);
} catch {
// field renamed/removed in a newer catalog — ignore, keep default
}
}
created.set(node.id, block);
}
// Pass 2 — connections (stacks then slots).
for (const node of doc.nodes) {
const block = created.get(node.id);
if (!block) continue;
if (node.next) {
const target = created.get(node.next);
if (target && block.nextConnection && target.previousConnection) {
block.nextConnection.connect(target.previousConnection);
}
}
for (const [slot, headId] of Object.entries(node.slots ?? {})) {
const head = created.get(headId);
const input = block.getInput(slot);
if (head && input?.connection && head.previousConnection) {
input.connection.connect(head.previousConnection);
}
prevBlock = selBlock;
}
}
// Create puzzle config blocks in a second column
let xOffset = 420;
yOffset = 20;
for (const puzzle of scenario.puzzles) {
const block = createPuzzleBlock(workspace, puzzle);
if (block) {
initBlock(block, xOffset, yOffset);
yOffset += 80;
// Pass 3 — render and position roots (children follow their parent).
for (const node of doc.nodes) {
const block = created.get(node.id);
if (!block) continue;
const svg = block as Blockly.BlockSvg;
if (typeof svg.initSvg === 'function') svg.initSvg();
if (typeof svg.render === 'function') svg.render();
if (!block.getParent()) {
const [x, y] = node.position;
if (x !== 0 || y !== 0) svg.moveBy(x, y);
}
}
if (workspace instanceof Blockly.WorkspaceSvg) {
Blockly.svgResize(workspace);
}
}
function initBlock(block: Blockly.Block, x: number, y: number): void {
if (typeof (block as Blockly.BlockSvg).initSvg === 'function') {
(block as Blockly.BlockSvg).initSvg();
}
if (typeof (block as Blockly.BlockSvg).render === 'function') {
(block as Blockly.BlockSvg).render();
}
if (x !== 0 || y !== 0) {
(block as Blockly.BlockSvg).moveBy(x, y);
}
}
const PUZZLE_TYPE_MAP: Record<string, string> = {
P1_SON: 'puzzle_sequence_sonore',
P2_CIRCUIT: 'puzzle_circuit_led',
P3_QR: 'puzzle_qr_treasure',
P4_RADIO: 'puzzle_radio',
P5_MORSE: 'puzzle_morse',
P6_SYMBOLES: 'puzzle_symboles_nfc',
P7_COFFRE: 'puzzle_coffre_final',
};
function createPuzzleBlock(workspace: Blockly.Workspace, puzzle: PuzzleConfig): Blockly.Block | null {
const blockType = PUZZLE_TYPE_MAP[puzzle.id];
if (!blockType) return null;
const block = workspace.newBlock(blockType) as Blockly.Block;
switch (puzzle.id) {
case 'P1_SON':
block.setFieldValue(puzzle.melody_notes?.join(',') ?? '', 'MELODY_NOTES');
block.setFieldValue(String(puzzle.difficulty ?? 1), 'DIFFICULTY');
block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
break;
case 'P2_CIRCUIT':
block.setFieldValue(puzzle.components?.join(',') ?? '', 'COMPONENTS');
block.setFieldValue(puzzle.valid_circuit ?? '', 'VALID_CIRCUIT');
block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
break;
case 'P3_QR':
block.setFieldValue(puzzle.qr_codes?.join(',') ?? '', 'QR_CODES');
block.setFieldValue(puzzle.correct_order?.join(',') ?? '', 'CORRECT_ORDER');
block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
break;
case 'P4_RADIO':
block.setFieldValue(String(puzzle.target_freq ?? 93.5), 'TARGET_FREQ');
block.setFieldValue(String(puzzle.range_min ?? 93.0), 'RANGE_MIN');
block.setFieldValue(String(puzzle.range_max ?? 94.0), 'RANGE_MAX');
block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
break;
case 'P5_MORSE':
block.setFieldValue(puzzle.message ?? 'SOS', 'MESSAGE');
block.setFieldValue(puzzle.mode ?? 'tech', 'MODE');
block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
break;
case 'P6_SYMBOLES':
block.setFieldValue(puzzle.symbols?.join(',') ?? '', 'SYMBOLS');
block.setFieldValue(puzzle.correct_order?.join(',') ?? '', 'CORRECT_ORDER');
block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
break;
case 'P7_COFFRE':
block.setFieldValue(String(puzzle.code_length ?? 8), 'CODE_LENGTH');
block.setFieldValue(String(puzzle.max_attempts ?? 3), 'MAX_ATTEMPTS');
break;
}
return block;
return created.size;
}
+162 -22
View File
@@ -1,3 +1,7 @@
@import '@fontsource/orbitron/400.css';
@import '@fontsource/orbitron/700.css';
@import '@fontsource/ibm-plex-mono/400.css';
* {
box-sizing: border-box;
}
@@ -21,19 +25,159 @@ body {
color: #e8e8e8;
}
.atelier-shell {
.atelier-root {
height: 100vh;
display: flex;
flex-direction: column;
}
.atelier-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #0a1722;
color: #e8eef3;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
.atelier-shell::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.05;
background-image: linear-gradient(#FF8800 1px, transparent 1px),
linear-gradient(90deg, #FF8800 1px, transparent 1px);
background-size: 32px 32px;
}
.atelier-menubar {
flex: 0 0 auto;
height: 40px;
display: flex;
align-items: center;
gap: 16px;
padding: 0 14px;
background: #15151a;
border-bottom: 1px solid #2c2c2e;
user-select: none;
}
.atelier-menubar__title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: #fbbf24;
}
.atelier-menubar__group {
display: flex;
gap: 6px;
}
.atelier-menubar__btn {
padding: 5px 12px;
border: 1px solid #2c2c2e;
border-radius: 5px;
background: #232327;
color: #e8e8e8;
font-size: 12px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.atelier-menubar__btn:hover:not(:disabled) {
background: #3c3c4a;
border-color: #4d4d52;
}
.atelier-menubar__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.atelier-menubar__notice {
font-size: 12px;
color: #86efac;
}
.atelier-menubar__dropdown {
position: relative;
}
.atelier-menubar__backdrop {
position: fixed;
inset: 0;
z-index: 60;
}
.atelier-menubar__list {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 61;
margin: 0;
padding: 4px;
list-style: none;
min-width: 280px;
background: #1f1f22;
border: 1px solid #3c3c4a;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.atelier-menubar__item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: #e8e8e8;
text-align: left;
cursor: pointer;
}
.atelier-menubar__item:hover {
background: #3c3c4a;
}
.atelier-menubar__item-name {
font-size: 13px;
font-weight: 600;
}
.atelier-menubar__item-desc {
font-size: 11px;
color: #9a9a9d;
line-height: 1.3;
}
/* Anchored popover hosting a full panel body (game config / deploy / assets). */
.atelier-menubar__panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 61;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.atelier-menubar__btn--active {
background: #3c3c4a;
border-color: #4d4d52;
}
.atelier-pane {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #232327;
border: 1px solid #2c2c2e;
background: #0f2233;
border: 1px solid #1c3b52;
color: #9a9a9d;
font-size: 14px;
user-select: none;
@@ -61,14 +205,14 @@ body {
}
.atelier-resizer {
background: #0e0e10;
background: #1c3b52;
transition: background 120ms ease;
}
.atelier-resizer:hover,
.atelier-resizer[data-resize-handle-state='hover'],
.atelier-resizer[data-resize-handle-state='drag'] {
background: #4d4d52;
background: #FF8800;
}
.atelier-resizer--vertical {
@@ -81,23 +225,19 @@ body {
cursor: row-resize;
}
.atelier-mode-tabs {
display: flex;
gap: 4px;
/* ── P3a: LCD scene preview effect animations (mirror display_ui.cpp) ── */
@keyframes zacus-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.atelier-mode-tab {
padding: 4px 10px;
border: 1px solid #2c2c2e;
border-radius: 4px;
background: #232327;
color: #9a9a9d;
font-size: 12px;
cursor: pointer;
@keyframes zacus-glitch {
0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
20% { transform: translate(-2px, 1px); clip-path: inset(20% 0 30% 0); }
40% { transform: translate(2px, -1px); clip-path: inset(50% 0 10% 0); }
60% { transform: translate(-1px, 0); clip-path: inset(10% 0 60% 0); }
80% { transform: translate(1px, 1px); clip-path: inset(40% 0 20% 0); }
}
.atelier-mode-tab--active {
background: #3c3c4a;
color: #fff;
border-color: #4d4d52;
@keyframes zacus-gyro-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -1,72 +0,0 @@
import { useEffect } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { useSimStore } from '../stores/simStore.js';
// Cinematic camera waypoints (position + target)
const WAYPOINTS: Array<{ pos: [number, number, number]; target: [number, number, number]; duration: number }> = [
{ pos: [0, 3, 6], target: [0, 0, 0], duration: 5000 },
{ pos: [-4, 2, -2], target: [-4, 0, -2], duration: 4000 },
{ pos: [-4, 2, 2], target: [-4, 0, 2], duration: 4000 },
{ pos: [0, 2, -3.5], target: [0, 0, -3.5], duration: 4000 },
{ pos: [4, 2, -2], target: [4, 0, -2], duration: 4000 },
{ pos: [4, 2, 2], target: [4, 0, 2], duration: 4000 },
{ pos: [0, 4, 0], target: [0, 0, 0], duration: 6000 },
];
/**
* DemoMode: auto-play with cinematic camera moving between puzzle stations.
* Mount inside a Canvas as a child component (uses useFrame + useThree).
*/
export function DemoCameraController() {
const { camera } = useThree();
const mode = useSimStore((s) => s.mode);
let waypointIndex = 0;
let elapsed = 0;
useFrame((_, delta) => {
if (mode !== 'demo') return;
elapsed += delta * 1000;
const wp = WAYPOINTS[waypointIndex % WAYPOINTS.length]!;
if (elapsed >= wp.duration) {
elapsed = 0;
waypointIndex = (waypointIndex + 1) % WAYPOINTS.length;
}
const t = Math.min(elapsed / wp.duration, 1);
const smoothT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // ease in-out
const targetPos = new THREE.Vector3(...wp.pos);
camera.position.lerp(targetPos, smoothT * delta * 2);
const lookTarget = new THREE.Vector3(...wp.target);
camera.lookAt(lookTarget);
});
return null;
}
/**
* DemoMode component: auto-solves puzzles at intervals for showcase.
*/
export function DemoMode() {
const { solvePuzzle, solvedPuzzles } = useSimStore();
const PUZZLE_SEQUENCE = ['P1_SON', 'P2_CIRCUIT', 'P3_QR', 'P4_RADIO', 'P5_MORSE', 'P6_SYMBOLES', 'P7_COFFRE'];
useEffect(() => {
if (solvedPuzzles.length >= PUZZLE_SEQUENCE.length) return;
const timer = setInterval(() => {
const nextPuzzle = PUZZLE_SEQUENCE[solvedPuzzles.length];
if (nextPuzzle && !solvedPuzzles.includes(nextPuzzle)) {
solvePuzzle(nextPuzzle);
}
}, 8000);
return () => clearInterval(timer);
}, [solvedPuzzles.length, solvePuzzle]);
return null;
}
@@ -1,33 +0,0 @@
import type { CSSProperties } from 'react';
const HUD_STYLE: CSSProperties = {
position: 'absolute',
bottom: 16,
right: 16,
background: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
borderRadius: 12,
padding: 12,
fontSize: 12,
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
};
/**
* SandboxMode: free orbit camera + click-to-interact.
* OrbitControls live in RoomScene when mode === 'sandbox'.
* This component renders the HUD legend.
*/
export function SandboxMode() {
return (
<div style={HUD_STYLE}>
<div style={{ fontWeight: 600, marginBottom: 4, color: '#0071e3' }}>
Mode Sandbox
</div>
<div>Clic gauche : orbite</div>
<div>Clic droit : panoramique</div>
<div>Molette : zoom</div>
<div style={{ marginTop: 4 }}>Cliquer sur un puzzle pour interagir</div>
</div>
);
}
@@ -1,44 +0,0 @@
import { useEffect } from 'react';
import { useSimStore } from '../stores/simStore.js';
const HUD_STYLE: React.CSSProperties = {
position: 'absolute',
bottom: 16,
left: 16,
background: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
borderRadius: 16,
padding: 16,
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
};
export function TestMode() {
const { engineState, solvedPuzzles, tickEngine } = useSimStore();
useEffect(() => {
const interval = setInterval(() => {
tickEngine(Date.now());
}, 100);
return () => clearInterval(interval);
}, [tickEngine]);
return (
<div style={HUD_STYLE}>
<div style={{ fontWeight: 700, marginBottom: 8, color: '#0071e3' }}>
Test Mode (10x speed)
</div>
<div>Phase: {engineState?.phase ?? '—'}</div>
<div>Profile: {engineState?.groupProfile ?? 'detecting...'}</div>
<div>Solved: {solvedPuzzles.join(', ') || 'none'}</div>
<div>Code: {engineState?.codeAssembled ?? '________'}</div>
<div>Mood: {engineState?.npcMood ?? '—'}</div>
<div>Elapsed: {engineState ? Math.round(engineState.elapsedMs / 1000) : 0}s</div>
<div style={{ marginTop: 8, color: 'rgba(255,255,255,0.4)' }}>
Click puzzles to solve them
</div>
</div>
);
}
@@ -1,62 +0,0 @@
import { useState } from 'react';
import { Text } from '@react-three/drei';
import { useSimStore } from '../stores/simStore.js';
interface Props {
position: [number, number, number];
}
const COLORS = ['#ff3b30', '#34c759', '#0071e3', '#ffcc00'];
const TARGET = [0, 2, 1, 3]; // melody pattern to reproduce
function playTone(hz: number, duration = 0.2) {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = hz;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}
export function P1Sound({ position }: Props) {
const { solvePuzzle, mode } = useSimStore();
const [pressed, setPressed] = useState<number[]>([]);
const handleButtonPress = (i: number) => {
if (mode === 'demo') return;
const newPressed = [...pressed, i];
setPressed(newPressed);
playTone(220 + i * 110);
if (newPressed.length === TARGET.length) {
if (newPressed.every((v, j) => v === TARGET[j])) {
solvePuzzle('P1_SON');
}
setPressed([]);
}
};
return (
<group position={position}>
<mesh castShadow>
<boxGeometry args={[0.8, 0.3, 0.8]} />
<meshStandardMaterial color="#2c2c2e" />
</mesh>
{COLORS.map((color, i) => (
<mesh
key={i}
position={[(i % 2) * 0.3 - 0.15, 0.2, Math.floor(i / 2) * 0.3 - 0.15]}
onClick={() => handleButtonPress(i)}
>
<cylinderGeometry args={[0.08, 0.08, 0.06, 16]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.3} />
</mesh>
))}
<Text position={[0, 0.5, 0]} fontSize={0.1} color="white" anchorX="center">
P1 Son
</Text>
</group>
);
}
@@ -1,87 +0,0 @@
import { useState, useRef } from 'react';
import { Text } from '@react-three/drei';
import { useSimStore } from '../stores/simStore.js';
interface Props {
position: [number, number, number];
}
// Morse code lookup table
const MORSE: Record<string, string> = {
A:'.-', B:'-...', C:'-.-.', D:'-..', E:'.', F:'..-.',
G:'--.', H:'....', I:'..', J:'.---', K:'-.-', L:'.-..',
M:'--', N:'-.', O:'---', P:'.--.', Q:'--.-', R:'.-.',
S:'...', T:'-', U:'..-', V:'...-', W:'.--', X:'-..-',
Y:'-.--', Z:'--..', '0':'-----', '1':'.----', '2':'..---',
'3':'...--', '4':'....-', '5':'.....', '6':'-....', '7':'--...',
'8':'---..', '9':'----.',
};
const TARGET_MESSAGE = 'SOS';
const targetMorse = TARGET_MESSAGE.split('').map((c) => MORSE[c] ?? '').join(' ');
function playTone(hz: number, duration = 0.1) {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = hz;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}
export function P5Morse({ position }: Props) {
const { solvePuzzle, mode } = useSimStore();
const [active, setActive] = useState(false);
const [inputMorse, setInputMorse] = useState('');
const pressStartRef = useRef<number>(0);
const dotDashThreshold = 200; // ms — below = dot, above = dash
const handlePress = () => {
if (mode === 'demo') return;
setActive(true);
pressStartRef.current = Date.now();
playTone(800, 0.05);
};
const handleRelease = () => {
if (mode === 'demo') return;
setActive(false);
const duration = Date.now() - pressStartRef.current;
const symbol = duration < dotDashThreshold ? '.' : '-';
setInputMorse((prev) => {
const next = prev + symbol;
if (next.replace(/ /g, '') === targetMorse.replace(/ /g, '')) {
solvePuzzle('P5_MORSE');
}
return next;
});
};
return (
<group position={position}>
{/* Base */}
<mesh castShadow>
<boxGeometry args={[0.6, 0.1, 0.4]} />
<meshStandardMaterial color="#3a3a3a" metalness={0.7} />
</mesh>
{/* Telegraph key arm */}
<mesh
position={[0, active ? 0.05 : 0.1, 0]}
onPointerDown={handlePress}
onPointerUp={handleRelease}
>
<boxGeometry args={[0.4, 0.04, 0.08]} />
<meshStandardMaterial color="#c0c0c0" metalness={0.9} emissive={active ? '#ffffff' : '#000000'} emissiveIntensity={active ? 0.3 : 0} />
</mesh>
<Text position={[0, 0.3, 0]} fontSize={0.07} color="white" anchorX="center">
P5 Morse
</Text>
<Text position={[0, 0.42, 0]} fontSize={0.05} color="#00ff00" anchorX="center">
{inputMorse.slice(-20)}
</Text>
</group>
);
}
@@ -1,94 +0,0 @@
import { useState } from 'react';
import { Text } from '@react-three/drei';
import { useSimStore } from '../stores/simStore.js';
interface Props {
position: [number, number, number];
}
const SYMBOLS = ['α', 'β', 'γ', 'δ', 'σ', 'ω', 'φ', 'ψ', 'χ', 'ρ', 'μ', 'ν'];
const CORRECT_ORDER = [0, 3, 6, 1]; // indices of correct symbols in order
const SLOT_COUNT = 4;
export function P6Symbols({ position }: Props) {
const { solvePuzzle, mode } = useSimStore();
const [slots, setSlots] = useState<(number | null)[]>(Array(SLOT_COUNT).fill(null));
const [selected, setSelected] = useState<number | null>(null);
const handleTileClick = (idx: number) => {
if (mode === 'demo') return;
setSelected(idx);
};
const handleSlotClick = (slotIdx: number) => {
if (mode === 'demo' || selected === null) return;
const newSlots = [...slots];
newSlots[slotIdx] = selected;
setSlots(newSlots);
setSelected(null);
// Check if complete and correct
if (newSlots.every((s) => s !== null)) {
const correct = newSlots.every((s, i) => s === CORRECT_ORDER[i]);
if (correct) solvePuzzle('P6_SYMBOLES');
}
};
return (
<group position={position}>
{/* Board */}
<mesh castShadow>
<boxGeometry args={[0.8, 0.05, 0.6]} />
<meshStandardMaterial color="#5a3a1a" roughness={0.9} />
</mesh>
{/* Symbol tiles */}
{SYMBOLS.slice(0, 8).map((sym, i) => (
<mesh
key={i}
position={[(i % 4) * 0.18 - 0.27, 0.08, Math.floor(i / 4) * 0.18 - 0.09]}
onClick={() => handleTileClick(i)}
>
<boxGeometry args={[0.14, 0.04, 0.14]} />
<meshStandardMaterial
color={selected === i ? '#0071e3' : '#c8a060'}
emissive={selected === i ? '#0071e3' : '#000000'}
emissiveIntensity={selected === i ? 0.5 : 0}
/>
</mesh>
))}
{/* Symbol text labels */}
{SYMBOLS.slice(0, 8).map((sym, i) => (
<Text
key={`sym-${i}`}
position={[(i % 4) * 0.18 - 0.27, 0.12, Math.floor(i / 4) * 0.18 - 0.09]}
fontSize={0.08}
color="#1a1a1a"
anchorX="center"
>
{sym}
</Text>
))}
{/* Slots */}
{Array.from({ length: SLOT_COUNT }, (_, i) => (
<mesh
key={`slot-${i}`}
position={[i * 0.18 - 0.27, 0.08, 0.22]}
onClick={() => handleSlotClick(i)}
>
<boxGeometry args={[0.14, 0.04, 0.14]} />
<meshStandardMaterial
color={slots[i] !== null ? '#34c759' : '#3a3a3c'}
emissive={slots[i] !== null ? '#34c759' : '#000000'}
emissiveIntensity={slots[i] !== null ? 0.3 : 0}
/>
</mesh>
))}
<Text position={[0, 0.4, 0]} fontSize={0.07} color="white" anchorX="center">
P6 Symboles
</Text>
</group>
);
}
@@ -1,76 +0,0 @@
import { useState } from 'react';
import { Text } from '@react-three/drei';
import { useSimStore } from '../stores/simStore.js';
interface Props {
position: [number, number, number];
}
const CORRECT_CODE = '12345678'; // assembled from all puzzles
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
export function P7Coffre({ position }: Props) {
const { solvePuzzle, mode } = useSimStore();
const [input, setInput] = useState('');
const [error, setError] = useState(false);
const handleKey = (digit: string) => {
if (mode === 'demo') return;
if (!/^\d$/.test(digit)) return; // ignore * and #
const newInput = input + digit;
setInput(newInput);
setError(false);
if (newInput.length === 8) {
if (newInput === CORRECT_CODE) {
solvePuzzle('P7_COFFRE');
} else {
setError(true);
setTimeout(() => { setInput(''); setError(false); }, 800);
}
}
};
return (
<group position={position}>
{/* Safe body */}
<mesh castShadow>
<boxGeometry args={[1, 1, 0.8]} />
<meshStandardMaterial color="#2c2c2e" metalness={0.5} />
</mesh>
{/* Keypad grid 3x4 */}
{KEYS.map((k, i) => (
<mesh
key={k}
position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.41]}
onClick={() => handleKey(k)}
>
<boxGeometry args={[0.18, 0.14, 0.04]} />
<meshStandardMaterial color={error ? '#ff3b30' : '#3a3a3a'} />
</mesh>
))}
{/* Key labels */}
{KEYS.map((k, i) => (
<Text
key={`label-${k}-${i}`}
position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.44]}
fontSize={0.07}
color="white"
anchorX="center"
>
{k}
</Text>
))}
{/* Display */}
<Text position={[0, 0.95, 0.41]} fontSize={0.07} color={error ? '#ff3b30' : '#00ff00'} anchorX="center">
{input.padEnd(8, '_')}
</Text>
<Text position={[0, 1.2, 0]} fontSize={0.1} color="white" anchorX="center">
P7 Coffre
</Text>
</group>
);
}
@@ -1,28 +0,0 @@
import { Html } from '@react-three/drei';
import { useSimStore } from '../stores/simStore.js';
export function NpcBubble() {
const npcLastPhrase = useSimStore((s) => s.npcLastPhrase);
if (!npcLastPhrase) return null;
return (
<Html position={[0, 1.2, 0]} center>
<div
style={{
background: 'rgba(0,0,0,0.85)',
color: 'white',
padding: '8px 14px',
borderRadius: 12,
fontSize: 13,
maxWidth: 220,
textAlign: 'center',
border: '1px solid rgba(255,255,255,0.15)',
backdropFilter: 'blur(8px)',
pointerEvents: 'none',
}}
>
{npcLastPhrase}
</div>
</Html>
);
}
@@ -1,23 +0,0 @@
import { Text } from '@react-three/drei';
import type { ReactNode } from 'react';
interface Props {
position: [number, number, number];
label: string;
onClick?: () => void;
children?: ReactNode;
}
/**
* Generic puzzle station wrapper: positions content + renders a floating label.
*/
export function PuzzleStation({ position, label, onClick, children }: Props) {
return (
<group position={position} {...(onClick ? { onClick } : {})}>
{children}
<Text position={[0, 0.6, 0]} fontSize={0.1} color="white" anchorX="center">
{label}
</Text>
</group>
);
}
@@ -1,138 +0,0 @@
import { useRef, useState } from 'react';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import { useSimStore } from '../stores/simStore.js';
import { P1Sound } from '../puzzles/P1Sound.js';
import { P5Morse } from '../puzzles/P5Morse.js';
import { P6Symbols } from '../puzzles/P6Symbols.js';
import { P7Coffre } from '../puzzles/P7Coffre.js';
// Positions arranged around the central table (polar layout)
const STATION_POSITIONS: Record<string, [number, number, number]> = {
P1_SON: [-4, 0, -2],
P2_CIRCUIT: [-4, 0, 2],
P3_QR: [ 0, 0, -3.5],
P4_RADIO: [ 4, 0, -2],
P5_MORSE: [ 4, 0, 2],
P6_SYMBOLES: [ 0, 0, 3.5],
P7_COFFRE: [ 5, 0, 0],
};
function playTone(hz: number, duration = 0.2) {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = hz;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}
export function PuzzleStations() {
return (
<group>
<P1Sound position={STATION_POSITIONS['P1_SON']!} />
<P2CircuitLed position={STATION_POSITIONS['P2_CIRCUIT']!} />
<P3QrTreasure position={STATION_POSITIONS['P3_QR']!} />
<P4Radio position={STATION_POSITIONS['P4_RADIO']!} />
<P5Morse position={STATION_POSITIONS['P5_MORSE']!} />
<P6Symbols position={STATION_POSITIONS['P6_SYMBOLES']!} />
<P7Coffre position={STATION_POSITIONS['P7_COFFRE']!} />
</group>
);
}
// ---- P2: Circuit LED ----
function P2CircuitLed({ position }: { position: [number, number, number] }) {
const { solvePuzzle } = useSimStore();
return (
<group position={position}>
<mesh castShadow onClick={() => solvePuzzle('P2_CIRCUIT')}>
<boxGeometry args={[0.9, 0.1, 0.7]} />
<meshStandardMaterial color="#1a2a1a" />
</mesh>
<Text position={[0, 0.3, 0]} fontSize={0.1} color="white" anchorX="center">
P2 Circuit
</Text>
</group>
);
}
// ---- P3: QR Treasure ----
function P3QrTreasure({ position }: { position: [number, number, number] }) {
const { solvePuzzle } = useSimStore();
const [scanned, setScanned] = useState(0);
const handleScan = (i: number) => {
if (i !== scanned) return; // must scan in order
const next = scanned + 1;
setScanned(next);
if (next === 6) solvePuzzle('P3_QR');
};
return (
<group position={position}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<mesh
key={i}
position={[(i % 3) * 0.3 - 0.3, 1.5, -3.4 + Math.floor(i / 3) * 0.4]}
onClick={() => handleScan(i)}
>
<boxGeometry args={[0.25, 0.25, 0.02]} />
<meshStandardMaterial color={i < scanned ? '#34c759' : '#f5f5dc'} />
</mesh>
))}
<Text position={[0, 2.2, -3.4]} fontSize={0.1} color="white" anchorX="center">
P3 QR
</Text>
</group>
);
}
// ---- P4: Radio ----
function P4Radio({ position }: { position: [number, number, number] }) {
const { solvePuzzle, mode } = useSimStore();
const dialRef = useRef<THREE.Mesh>(null);
const [freq, setFreq] = useState(88.0);
const TARGET_FREQ = 93.5;
const handleDialDrag = (delta: number) => {
if (mode === 'demo') return;
const newFreq = Math.max(88, Math.min(108, freq + delta * 0.1));
const rounded = parseFloat(newFreq.toFixed(1));
setFreq(rounded);
if (Math.abs(rounded - TARGET_FREQ) < 0.2) {
solvePuzzle('P4_RADIO');
}
};
return (
<group position={position}>
{/* Radio body */}
<mesh castShadow>
<boxGeometry args={[1.2, 0.6, 0.5]} />
<meshStandardMaterial color="#8B4513" roughness={0.8} />
</mesh>
{/* Dial */}
<mesh
ref={dialRef}
position={[0.3, 0.1, 0.26]}
onPointerMove={(e) => {
if (e.buttons === 1) handleDialDrag(e.movementX);
}}
>
<cylinderGeometry args={[0.12, 0.12, 0.05, 16]} />
<meshStandardMaterial color="#c0c0c0" metalness={0.9} roughness={0.1} />
</mesh>
{/* Frequency display */}
<Text position={[-0.1, 0.05, 0.26]} fontSize={0.08} color="#00ff00">
{freq.toFixed(1)} MHz
</Text>
<Text position={[0, 0.5, 0]} fontSize={0.1} color="white" anchorX="center">
P4 Radio
</Text>
</group>
);
}
@@ -1,33 +0,0 @@
/**
* Low-poly room geometry: floor, ceiling, 4 walls.
*/
export function Room() {
const walls = [
{ pos: [0, 2, -4] as [number, number, number], rot: [0, 0, 0] as [number, number, number], size: [12, 4] as [number, number] },
{ pos: [0, 2, 4] as [number, number, number], rot: [0, Math.PI, 0] as [number, number, number], size: [12, 4] as [number, number] },
{ pos: [-6, 2, 0] as [number, number, number], rot: [0, Math.PI / 2, 0] as [number, number, number], size: [8, 4] as [number, number] },
{ pos: [6, 2, 0] as [number, number, number], rot: [0, -Math.PI / 2, 0] as [number, number, number], size: [8, 4] as [number, number] },
];
return (
<group>
{/* Floor */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
<planeGeometry args={[12, 8]} />
<meshStandardMaterial color="#1a1a1a" roughness={0.9} metalness={0.1} />
</mesh>
{/* Ceiling */}
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, 4, 0]}>
<planeGeometry args={[12, 8]} />
<meshStandardMaterial color="#111111" />
</mesh>
{/* Walls */}
{walls.map((wall, i) => (
<mesh key={i} position={wall.pos} rotation={wall.rot} receiveShadow>
<planeGeometry args={wall.size} />
<meshStandardMaterial color="#1e1e2e" roughness={1} />
</mesh>
))}
</group>
);
}
@@ -1,56 +0,0 @@
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment, Sparkles } from '@react-three/drei';
import { Suspense } from 'react';
import { Room } from './Room.js';
import { PuzzleStations } from './PuzzleStations.js';
import { RtcPhone } from './RtcPhone.js';
import { NpcBubble } from './NpcBubble.js';
import { useSimStore } from '../stores/simStore.js';
import { DemoCameraController } from '../modes/DemoMode.js';
export function RoomScene() {
const mode = useSimStore((s) => s.mode);
return (
<Canvas
camera={{ position: [0, 3, 6], fov: 60 }}
shadows
gl={{ antialias: true }}
>
<Suspense fallback={null}>
{/* Lighting */}
<ambientLight intensity={0.3} />
<spotLight
position={[0, 5, 0]}
angle={0.6}
penumbra={0.8}
intensity={1.2}
castShadow
shadow-mapSize={[1024, 1024]}
/>
{/* Atmospheric particles */}
<Sparkles count={80} scale={[6, 4, 6]} size={1.5} speed={0.2} opacity={0.3} color="#ffccaa" />
{/* Room geometry */}
<Room />
{/* Central phone / NPC avatar */}
<RtcPhone position={[0, 0, 0]} />
{/* 7 puzzle stations */}
<PuzzleStations />
{/* NPC speech bubbles */}
<NpcBubble />
{/* Camera control */}
{mode === 'sandbox' && <OrbitControls makeDefault />}
{mode === 'demo' && <DemoCameraController />}
<Environment preset="night" />
<fog attach="fog" args={['#0a0a0f', 8, 20]} />
</Suspense>
</Canvas>
);
}
@@ -1,48 +0,0 @@
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useSimStore } from '../stores/simStore.js';
import { NPC_MOOD_COLORS } from '@zacus/shared';
interface Props {
position: [number, number, number];
}
export function RtcPhone({ position }: Props) {
const npcMood = useSimStore((s) => s.npcMood);
const glowRef = useRef<THREE.PointLight>(null);
const moodColor = NPC_MOOD_COLORS[npcMood] ?? '#0071e3';
useFrame(({ clock }) => {
if (!glowRef.current) return;
// Pulsing glow effect
glowRef.current.intensity = 0.5 + 0.3 * Math.sin(clock.elapsedTime * 2);
});
return (
<group position={position}>
{/* Table */}
<mesh castShadow position={[0, -0.02, 0]}>
<cylinderGeometry args={[0.8, 0.8, 0.04, 32]} />
<meshStandardMaterial color="#3a2a1a" roughness={0.8} />
</mesh>
{/* Phone body */}
<mesh castShadow position={[0, 0.15, 0]}>
<boxGeometry args={[0.3, 0.25, 0.15]} />
<meshStandardMaterial color="#1a1a1a" metalness={0.4} />
</mesh>
{/* Handset */}
<mesh castShadow position={[0, 0.32, 0]} rotation={[0, 0, -0.3]}>
<boxGeometry args={[0.2, 0.06, 0.06]} />
<meshStandardMaterial color="#111111" />
</mesh>
{/* Mood glow */}
<pointLight ref={glowRef} color={moodColor} distance={2} />
<mesh position={[0, 0.1, 0]}>
<sphereGeometry args={[0.08, 16, 16]} />
<meshStandardMaterial color={moodColor} emissive={moodColor} emissiveIntensity={1} transparent opacity={0.7} />
</mesh>
</group>
);
}
@@ -0,0 +1,15 @@
import { create } from 'zustand';
/**
* Workbench selection state (phase C): which scenario step the device-screen
* preview and flow graph are focused on. `null` falls back to the entry step.
*/
interface SelectionState {
selectedStepId: string | null;
selectStep: (id: string | null) => void;
}
export const useSelectionStore = create<SelectionState>((set) => ({
selectedStepId: null,
selectStep: (selectedStepId) => set({ selectedStepId }),
}));
@@ -1,75 +0,0 @@
import { create } from 'zustand';
import type { NpcMood, EngineState } from '@zacus/shared';
import { ZacusScenarioEngine } from '@zacus/scenario-engine';
export type SimMode = 'demo' | 'sandbox' | 'test';
interface SimState {
mode: SimMode;
npcMood: NpcMood;
npcLastPhrase: string | null;
solvedPuzzles: string[];
engineState: EngineState | null;
engine: ZacusScenarioEngine | null;
setMode: (mode: SimMode) => void;
solvePuzzle: (id: string) => void;
loadScenario: (yaml: string) => void;
tickEngine: (nowMs: number) => void;
speakNpc: (phrase: string) => void;
}
export const useSimStore = create<SimState>((set, get) => ({
mode: 'sandbox',
npcMood: 'neutral',
npcLastPhrase: null,
solvedPuzzles: [],
engineState: null,
engine: null,
setMode(mode) { set({ mode }); },
loadScenario(yaml) {
const engine = new ZacusScenarioEngine();
engine.load(yaml);
engine.start({ targetDuration: 60, mode: '60' });
set({ engine, engineState: engine.getState() });
},
tickEngine(nowMs) {
const { engine } = get();
if (!engine) return;
const state = engine.tick(nowMs);
set({ engineState: state, npcMood: state.npcMood });
},
solvePuzzle(id) {
const { engine } = get();
set((s) => ({ solvedPuzzles: [...s.solvedPuzzles, id] }));
if (engine) {
const decisions = engine.onEvent({
type: 'puzzle_solved',
timestamp: Date.now(),
data: { puzzle_id: id },
});
for (const d of decisions) {
if (d.action === 'speak') {
const phrase = `Excellent ! Puzzle ${id} résolu !`;
get().speakNpc(phrase);
}
}
}
},
speakNpc(phrase) {
set({ npcLastPhrase: phrase });
// TTS via Web Speech API
if ('speechSynthesis' in window) {
const utt = new SpeechSynthesisUtterance(phrase);
utt.lang = 'fr-FR';
window.speechSynthesis.speak(utt);
}
// Clear bubble after 5s
setTimeout(() => set({ npcLastPhrase: null }), 5000);
},
}));
@@ -0,0 +1,18 @@
import { create } from 'zustand';
import type * as Blockly from 'blockly';
/**
* Holds the live Blockly workspace instance so non-editor components (the
* MenuBar load/share actions) can read/write blocks without prop-drilling the
* imperative ref through the layout tree. Registered by EditorPane once the
* lazy Blockly workspace mounts; stays focused on the handle only.
*/
export interface WorkspaceState {
workspace: Blockly.WorkspaceSvg | null;
setWorkspace: (workspace: Blockly.WorkspaceSvg | null) => void;
}
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
workspace: null,
setWorkspace: (workspace) => set({ workspace }),
}));
@@ -0,0 +1,9 @@
// Palette néo-rétro « Workbench » — alignée sur display_ui.cpp et lib/screenSpec.ts.
export const WB = {
bg: '#0a1722', panel: '#0f2233', panelBorder: '#1c3b52',
workbenchBlue: '#0055AA', orange: '#FF8800', value: '#FFFFFF', label: '#AAAAAA',
text: '#e8eef3', gridSubtle: '#13283a',
led: { ok: '#34d399', warn: '#fbbf24', err: '#fca5a5', off: '#3b4a57' },
fontTitle: "'Orbitron','Arial Narrow',sans-serif",
fontMono: "'IBM Plex Mono',ui-monospace,monospace",
} as const;
+11
View File
@@ -1 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Base URL of the zacus-gateway (e.g. https://zacus.saillant.cc/api). */
readonly VITE_GATEWAY_BASE_URL?: string;
/** Optional bearer token for the gateway (gen_token.py). */
readonly VITE_GATEWAY_TOKEN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+1 -2
View File
@@ -4,8 +4,7 @@
"rootDir": "src",
"outDir": "dist",
"composite": true,
"noEmit": false,
"types": ["@react-three/fiber"]
"noEmit": false
},
"include": ["src"],
"references": [
-1
View File
@@ -9,7 +9,6 @@ export default defineConfig({
output: {
manualChunks: {
blockly: ['blockly'],
three: ['three', '@react-three/fiber', '@react-three/drei'],
react: ['react', 'react-dom'],
},
},
@@ -0,0 +1 @@
VITE_GATEWAY_BASE_URL=https://zacus.saillant.cc/api
+12 -33
View File
@@ -4,53 +4,32 @@ import { test, expect } from '@playwright/test';
* Atelier golden-path smoke tests.
*
* The dev server is auto-started by playwright.config.ts (webServer).
* Tests assert: layout renders, lazy chunks load, ⌘B collapses the 3D
* stage, the Run button surfaces after a debounced editor change.
* Tests assert: layout renders, lazy Blockly chunk loads, ⌘B collapses the
* stage panel, the Run button surfaces after a debounced editor change.
*
* The Run-button test uses a dev-only window.__atelierStores hook
* (declared in App.tsx behind import.meta.env.DEV) to drive store
* state without dragging real Blockly blocks in headless mode.
*
* NOTE: the 3D simulation stage was removed (phase A of the workbench
* refactor). The right panel is now a placeholder ("Banc d'essai");
* there is no <canvas>, no mode tabs, and no simStore.
*/
test.describe('atelier — layout & lazy chunks', () => {
test('shell renders three drag-resizable panes', async ({ page }) => {
test('shell renders editor and stage placeholder panes', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'sandbox' })).toBeVisible();
await expect(page.getByRole('button', { name: 'demo' })).toBeVisible();
await expect(page.getByRole('button', { name: 'test' })).toBeVisible();
});
test('mode switching updates the active tab', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
const sandboxTab = page.getByRole('button', { name: 'sandbox' });
const testTab = page.getByRole('button', { name: 'test' });
await expect(sandboxTab).toHaveClass(/atelier-mode-tab--active/);
await testTab.click();
await expect(testTab).toHaveClass(/atelier-mode-tab--active/);
await expect(sandboxTab).not.toHaveClass(/atelier-mode-tab--active/);
await expect(page.getByText('Test Mode (10x speed)')).toBeVisible();
await expect(page.getByTestId('stage-pane')).toBeVisible();
});
});
test.describe('atelier — ⌘B stage toggle', () => {
test('toggleStage collapses and re-expands the 3D stage', async ({ page }) => {
test('toggleStage collapses and re-expands the stage panel', async ({ page }) => {
await page.goto('/');
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('stage-pane')).toBeVisible({ timeout: 15_000 });
// We assert on the StagePane wrapper, not the <canvas> element.
// R3F's <canvas> keeps its intrinsic width (574px) even when the
// parent flex-container shrinks to 0 — only a resize event would
// tell Three to recompute. The stage-pane div, on the other hand,
// follows its parent panel's flex size faithfully.
//
// We invoke window.__atelierToggleStage directly rather than firing
// Cmd+B / Ctrl+B because keyboard modifier handling in headless
// Chromium is unreliable across macOS / Linux CI runners. The keyboard
@@ -80,7 +59,7 @@ test.describe('atelier — ⌘B stage toggle', () => {
test.describe('atelier — Run button stale flow', () => {
test('Run button surfaces after a debounced editor change', async ({ page }) => {
await page.goto('/');
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
// Initially neither badge nor button is rendered.
await expect(page.getByRole('button', { name: /Run|stale/ })).toHaveCount(0);
@@ -131,7 +110,7 @@ test.describe('atelier — Blockly editor pipeline', () => {
const ws = (
window as unknown as { __atelierBlockly: { getWorkspace: () => WS } }
).__atelierBlockly.getWorkspace();
const block = ws.newBlock('puzzle_sequence_sonore');
const block = ws.newBlock('zacus_sceneStart');
block.initSvg();
block.render();
// Give Blockly its synchronous tick to fire change events.
@@ -0,0 +1,126 @@
/**
* Vague 1 — reporter blocks → condition string → boolean.
*
* The scenario conditions are opaque strings in a loose grammar
* (`score > 5`, `pct >= 60 and lives > 0`). The new Blockly reporter blocks
* compose visually and compile to that SAME string via
* `serializeReporter()` (shared, mirrored in the Python gateway). This test
* proves the round-trip: build reporter nodes → serialise → the resulting
* string evaluates as expected against a variable scope.
*
* The evaluator here is a tiny reference implementation of the loose grammar
* (the runtime/firmware owns the production one; the spec leaves `expr`
* evaluation deliberately loose). It exists to assert the serialisation is
* semantically correct, not to ship an engine evaluator.
*/
import { describe, it, expect } from 'vitest';
import { serializeReporter, type BlocksNode } from '@zacus/shared';
function byId(...nodes: BlocksNode[]): Record<string, BlocksNode> {
const m: Record<string, BlocksNode> = {};
for (const n of nodes) m[n.id] = n;
return m;
}
/** Reference evaluator for the loose condition grammar. Supports numbers,
* string literals, identifiers (from scope), comparisons, + - * /, and the
* `and`/`or`/`not` keywords. JS-based, parens-aware. */
function evalExpr(expr: string, scope: Record<string, number | string>): boolean {
// Translate the keyword operators to JS, keep === / !== loose-equality safe.
const js = expr
.replace(/\band\b/g, '&&')
.replace(/\bor\b/g, '||')
.replace(/\bnot\b/g, '!')
.replace(/(^|[^=!<>])={2}(?!=)/g, '$1===')
.replace(/!={1}(?!=)/g, '!==');
const names = Object.keys(scope);
const vals = names.map((k) => scope[k]);
// eslint-disable-next-line no-new-func
const fn = new Function(...names, `return (${js});`);
return Boolean(fn(...vals));
}
describe('reporter → condition string', () => {
it('valScore serialises to "score"', () => {
const score: BlocksNode = { id: 's', kind: 'valScore', position: [0, 0] };
expect(serializeReporter('s', byId(score))).toBe('score');
});
it('compare(score, ">", 5) → "score > 5" and evaluates correctly', () => {
const score: BlocksNode = { id: 's', kind: 'valScore', position: [0, 0] };
const five: BlocksNode = { id: 'n', kind: 'valNumber', position: [0, 0], params: { num: 5 } };
const cmp: BlocksNode = {
id: 'c', kind: 'opCompare', position: [0, 0],
params: { op: '>' }, inputs: { A: 's', B: 'n' },
};
const expr = serializeReporter('c', byId(score, five, cmp));
expect(expr).toBe('score > 5');
expect(evalExpr(expr, { score: 7 })).toBe(true);
expect(evalExpr(expr, { score: 3 })).toBe(false);
expect(evalExpr(expr, { score: 5 })).toBe(false);
});
it('var("pct") → "pct"', () => {
const v: BlocksNode = { id: 'v', kind: 'valVar', position: [0, 0], params: { name: 'pct' } };
expect(serializeReporter('v', byId(v))).toBe('pct');
});
it('and(compare, compare) wraps operands in parens', () => {
const nodes = byId(
{ id: 'pct', kind: 'valVar', position: [0, 0], params: { name: 'pct' } },
{ id: 'p60', kind: 'valNumber', position: [0, 0], params: { num: 60 } },
{ id: 'lives', kind: 'valVar', position: [0, 0], params: { name: 'lives' } },
{ id: 'l0', kind: 'valNumber', position: [0, 0], params: { num: 0 } },
{ id: 'cA', kind: 'opCompare', position: [0, 0], params: { op: '>=' }, inputs: { A: 'pct', B: 'p60' } },
{ id: 'cB', kind: 'opCompare', position: [0, 0], params: { op: '>' }, inputs: { A: 'lives', B: 'l0' } },
{ id: 'andN', kind: 'opBool', position: [0, 0], params: { op: 'and' }, inputs: { A: 'cA', B: 'cB' } },
);
const expr = serializeReporter('andN', nodes);
expect(expr).toBe('(pct >= 60) and (lives > 0)');
expect(evalExpr(expr, { pct: 80, lives: 2 })).toBe(true);
expect(evalExpr(expr, { pct: 80, lives: 0 })).toBe(false);
expect(evalExpr(expr, { pct: 50, lives: 2 })).toBe(false);
});
it('not(var) → "not (flag)"', () => {
const nodes = byId(
{ id: 'f', kind: 'valVar', position: [0, 0], params: { name: 'flag' } },
{ id: 'n', kind: 'opNot', position: [0, 0], inputs: { A: 'f' } },
);
const expr = serializeReporter('n', nodes);
expect(expr).toBe('not (flag)');
expect(evalExpr(expr, { flag: 0 })).toBe(true);
expect(evalExpr(expr, { flag: 1 })).toBe(false);
});
it('arith(score + 1) inside a compare', () => {
const nodes = byId(
{ id: 's', kind: 'valScore', position: [0, 0] },
{ id: 'one', kind: 'valNumber', position: [0, 0], params: { num: 1 } },
{ id: 'sum', kind: 'opArith', position: [0, 0], params: { op: '+' }, inputs: { A: 's', B: 'one' } },
{ id: 'ten', kind: 'valNumber', position: [0, 0], params: { num: 10 } },
{ id: 'cmp', kind: 'opCompare', position: [0, 0], params: { op: '>=' }, inputs: { A: 'sum', B: 'ten' } },
);
const expr = serializeReporter('cmp', nodes);
expect(expr).toBe('(score + 1) >= 10');
expect(evalExpr(expr, { score: 9 })).toBe(true);
expect(evalExpr(expr, { score: 8 })).toBe(false);
});
it('text literal is JSON-quoted', () => {
const nodes = byId(
{ id: 'g', kind: 'valVar', position: [0, 0], params: { name: 'guess' } },
{ id: 't', kind: 'valText', position: [0, 0], params: { text: 'oui' } },
{ id: 'c', kind: 'opCompare', position: [0, 0], params: { op: '==' }, inputs: { A: 'g', B: 't' } },
);
const expr = serializeReporter('c', nodes);
expect(expr).toBe('guess == "oui"');
expect(evalExpr(expr, { guess: 'oui' })).toBe(true);
expect(evalExpr(expr, { guess: 'non' })).toBe(false);
});
it('missing operand ref falls back to empty (so callers use text param)', () => {
expect(serializeReporter(undefined, {})).toBe('');
expect(serializeReporter('nope', {})).toBe('');
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { evalExpr, evalBool, evalNumber, ExprError } from '../src/expr-eval.js';
describe('expr-eval — literals', () => {
it('evaluates a number literal', () => {
expect(evalExpr('42')).toBe(42);
expect(evalExpr('3.5')).toBe(3.5);
expect(evalExpr(' 7 ')).toBe(7);
});
it('evaluates a text literal (double and single quotes)', () => {
expect(evalExpr('"oui"')).toBe('oui');
expect(evalExpr("'non'")).toBe('non');
});
it('reads variables from the scope', () => {
expect(evalExpr('score', { score: 12 })).toBe(12);
expect(evalExpr('pct', { pct: 60 })).toBe(60);
expect(evalExpr('guess', { guess: 'oui' })).toBe('oui');
});
it('treats an unknown variable as 0 (loose grammar)', () => {
expect(evalExpr('missing')).toBe(0);
});
});
describe('expr-eval — arithmetic', () => {
it('handles + - * / with precedence', () => {
expect(evalExpr('2 + 3 * 4')).toBe(14);
expect(evalExpr('(2 + 3) * 4')).toBe(20);
expect(evalExpr('10 / 4')).toBe(2.5);
expect(evalExpr('10 - 3 - 2')).toBe(5);
});
it('handles unary minus', () => {
expect(evalExpr('-5')).toBe(-5);
expect(evalExpr('3 + -2')).toBe(1);
expect(evalExpr('-(2 + 3)')).toBe(-5);
});
it('arithmetic with variables', () => {
expect(evalExpr('score + 1', { score: 9 })).toBe(10);
expect(evalExpr('score * 2 - 1', { score: 5 })).toBe(9);
});
});
describe('expr-eval — comparisons', () => {
it('==, !=, <, <=, >, >=', () => {
expect(evalExpr('score > 5', { score: 7 })).toBe(true);
expect(evalExpr('score > 5', { score: 5 })).toBe(false);
expect(evalExpr('score >= 5', { score: 5 })).toBe(true);
expect(evalExpr('score < 5', { score: 3 })).toBe(true);
expect(evalExpr('score <= 5', { score: 6 })).toBe(false);
expect(evalExpr('score == 5', { score: 5 })).toBe(true);
expect(evalExpr('score != 5', { score: 6 })).toBe(true);
});
it('compares strings with == / !=', () => {
expect(evalExpr('guess == "oui"', { guess: 'oui' })).toBe(true);
expect(evalExpr('guess == "oui"', { guess: 'non' })).toBe(false);
expect(evalExpr('guess != "oui"', { guess: 'non' })).toBe(true);
});
});
describe('expr-eval — booleans and/or/not', () => {
it('and / or', () => {
expect(evalExpr('(pct >= 60) and (lives > 0)', { pct: 80, lives: 2 })).toBe(true);
expect(evalExpr('(pct >= 60) and (lives > 0)', { pct: 80, lives: 0 })).toBe(false);
expect(evalExpr('(pct >= 60) and (lives > 0)', { pct: 50, lives: 2 })).toBe(false);
expect(evalExpr('(pct >= 60) or (lives > 0)', { pct: 50, lives: 2 })).toBe(true);
});
it('not', () => {
expect(evalExpr('not (flag)', { flag: 0 })).toBe(true);
expect(evalExpr('not (flag)', { flag: 1 })).toBe(false);
expect(evalExpr('not (pct >= 60)', { pct: 50 })).toBe(true);
});
it('and binds tighter than or', () => {
// false or (true and true) => true
expect(evalExpr('a or b and c', { a: 0, b: 1, c: 1 })).toBe(true);
// (false or true) and false would be false; correct precedence => true
expect(evalExpr('a or b and c', { a: 0, b: 1, c: 0 })).toBe(false);
});
it('arith inside compare inside bool', () => {
expect(evalExpr('(score + 1) >= 10', { score: 9 })).toBe(true);
expect(evalExpr('(score + 1) >= 10', { score: 8 })).toBe(false);
});
it('robust to extra whitespace and nested parens', () => {
expect(evalExpr(' ( ( score > 5 ) and ( not (flag) ) ) ', { score: 9, flag: 0 })).toBe(true);
});
});
describe('expr-eval — helpers', () => {
it('evalBool coerces to boolean (truthy numbers)', () => {
expect(evalBool('score', { score: 3 })).toBe(true);
expect(evalBool('score', { score: 0 })).toBe(false);
expect(evalBool('score > 5', { score: 7 })).toBe(true);
});
it('evalNumber coerces booleans to 1/0', () => {
expect(evalNumber('score > 5', { score: 7 })).toBe(1);
expect(evalNumber('score + 2', { score: 5 })).toBe(7);
});
});
describe('expr-eval — errors', () => {
it('throws ExprError on malformed input', () => {
expect(() => evalExpr('2 +')).toThrow(ExprError);
expect(() => evalExpr('(2 + 3')).toThrow(ExprError);
expect(() => evalExpr('2 3')).toThrow(ExprError);
expect(() => evalExpr('')).toThrow(ExprError);
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpFirmwareDriver } from '../src/firmware-driver.js';
import type { RuntimeIR } from '../src/runtime-ir.js';
const ir: RuntimeIR = {
schema_version: 'zacus.runtime3.v1',
scenario: { id: 'TEST', version: 3, title: 'T', entry_step_id: 'S1' },
steps: [{ id: 'S1' }],
};
function okJson(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
describe('HttpFirmwareDriver', () => {
it('pushScenario POSTs the IR to /game/scenario', async () => {
const fetchMock = vi.fn(async () => okJson({ ok: true }));
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
await driver.pushScenario(ir);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://master.local/game/scenario');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toMatchObject({ scenario: { id: 'TEST' } });
});
it('armStep POSTs {step_id} to /game/step', async () => {
const fetchMock = vi.fn(async () => okJson({ ok: true }));
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
await driver.armStep('S1');
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://master.local/game/step');
expect(JSON.parse(init?.body as string)).toEqual({ step_id: 'S1' });
});
it('getPuzzleState GETs /game/puzzle_state and parses JSON', async () => {
const fetchMock = vi.fn(async () => okJson({ solved: true, assembled_code: '125' }));
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
const state = await driver.getPuzzleState();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://master.local/game/puzzle_state');
expect(init?.method ?? 'GET').toBe('GET');
expect(state).toMatchObject({ solved: true, assembled_code: '125' });
});
it('setGroupProfile POSTs to /game/group_profile', async () => {
const fetchMock = vi.fn(async () => okJson({ ok: true }));
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
await driver.setGroupProfile({ profile: 'TECH' });
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://master.local/game/group_profile');
expect(JSON.parse(init?.body as string)).toMatchObject({ profile: 'TECH' });
});
it('strips a trailing slash from the base URL', async () => {
const fetchMock = vi.fn(async () => okJson({ ok: true }));
const driver = new HttpFirmwareDriver('http://master.local/', { fetch: fetchMock });
await driver.armStep('S1');
expect(fetchMock.mock.calls[0]![0]).toBe('http://master.local/game/step');
});
it('throws on a non-2xx response', async () => {
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
await expect(driver.armStep('S1')).rejects.toThrow(/500/);
});
});
@@ -0,0 +1,251 @@
import { describe, it, expect } from 'vitest';
import { ScenarioRunner } from '../src/runner.js';
import type { RuntimeIR, RuntimeAction } from '../src/runtime-ir.js';
import type { FirmwareDriver, PuzzleState } from '../src/firmware-driver.js';
function ir(steps: RuntimeIR['steps'], entry?: string): RuntimeIR {
return {
schema_version: 'zacus.runtime3.v1',
scenario: {
id: 'TEST',
version: 3,
title: 'Test',
entry_step_id: entry ?? steps[0]!.id,
},
steps,
};
}
// A stub driver that records calls; never touches the network.
function stubDriver(state: Partial<PuzzleState> = {}): FirmwareDriver & { calls: string[] } {
const calls: string[] = [];
return {
calls,
async pushScenario(doc) {
calls.push(`push:${doc.scenario.id}`);
},
async armStep(stepId) {
calls.push(`arm:${stepId}`);
},
async getPuzzleState() {
calls.push('state');
return { solved: true, assembled_code: '', ...state };
},
async setGroupProfile(p) {
calls.push(`profile:${p.profile ?? ''}`);
},
};
}
describe('ScenarioRunner — expressions / actions', () => {
it('executes set_var and score_add actions, updating scope', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{ kind: 'score_add', delta: 5 },
{ kind: 'set_var', name: 'flag', value: 'true' },
] }]),
);
runner.runStep('S1');
expect(runner.getVar('score')).toBe(5);
expect(runner.getVar('flag')).toBe('true');
});
it('condition action runs the then-branch when expr is truthy', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{ kind: 'score_add', delta: 4 },
{
kind: 'condition',
expr: 'score >= 3',
then: [{ kind: 'set_var', name: 'level', value: '2' }],
else: [{ kind: 'set_var', name: 'level', value: '1' }],
},
] }]),
);
runner.runStep('S1');
expect(runner.getVar('level')).toBe('2');
});
it('condition action runs the else-branch when expr is falsy', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{
kind: 'condition',
expr: 'score >= 3',
then: [{ kind: 'set_var', name: 'level', value: '2' }],
else: [{ kind: 'set_var', name: 'level', value: '1' }],
},
] }]),
);
runner.runStep('S1');
expect(runner.getVar('level')).toBe('1');
});
it('emits non-control actions (tts_say) to the sink', () => {
const emitted: RuntimeAction[] = [];
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [{ kind: 'tts_say', text: 'hello' }] }]),
{ onAction: (a) => emitted.push(a) },
);
runner.runStep('S1');
expect(emitted).toHaveLength(1);
expect(emitted[0]).toMatchObject({ kind: 'tts_say', text: 'hello' });
});
});
describe('ScenarioRunner — loop execution', () => {
it('repeat loop runs body N times', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{ kind: 'loop', mode: 'repeat', times: 3, body: [{ kind: 'score_add', delta: 2 }] },
] }]),
);
runner.runStep('S1');
expect(runner.getVar('score')).toBe(6);
});
it('while loop runs until expr is falsy', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{ kind: 'loop', mode: 'while', expr: 'score < 10', body: [{ kind: 'score_add', delta: 3 }] },
] }]),
);
runner.runStep('S1');
expect(runner.getVar('score')).toBe(12); // 0,3,6,9 -> +3 -> 12, then 12<10 false
});
it('while loop is bounded (no infinite loop)', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{ kind: 'loop', mode: 'while', expr: '1 > 0', body: [{ kind: 'tts_say', text: 'x' }] },
] }]),
{ maxLoopIterations: 50 },
);
expect(() => runner.runStep('S1')).toThrow(/loop iteration limit/i);
});
it('nested loop + condition compose', () => {
const runner = new ScenarioRunner(
ir([{ id: 'S1', actions: [
{
kind: 'loop', mode: 'repeat', times: 5, body: [
{ kind: 'score_add', delta: 1 },
{
kind: 'condition', expr: 'score == 3',
then: [{ kind: 'set_var', name: 'hit3', value: 'yes' }],
},
],
},
] }]),
);
runner.runStep('S1');
expect(runner.getVar('score')).toBe(5);
expect(runner.getVar('hit3')).toBe('yes');
});
});
describe('ScenarioRunner — transitions', () => {
it('selects unconditional transition by priority', () => {
const runner = new ScenarioRunner(
ir([
{ id: 'S1', transitions: [
{ event_type: 'action', target_step_id: 'B', priority: 1 },
{ event_type: 'action', target_step_id: 'A', priority: 0 },
] },
{ id: 'A' },
{ id: 'B' },
]),
);
expect(runner.nextStep('S1')).toBe('A');
});
it('branch transition uses expr to pick target', () => {
const runner = new ScenarioRunner(
ir([
{ id: 'S1', actions: [{ kind: 'score_add', delta: 9 }], transitions: [
{ event_type: 'branch', target_step_id: 'WIN', expr: 'score >= 5', priority: 0 },
{ event_type: 'branch', target_step_id: 'LOSE', priority: 1 },
] },
{ id: 'WIN' },
{ id: 'LOSE' },
]),
);
runner.runStep('S1');
expect(runner.nextStep('S1')).toBe('WIN');
});
it('branch transition skips when its expr is falsy', () => {
const runner = new ScenarioRunner(
ir([
{ id: 'S1', transitions: [
{ event_type: 'branch', target_step_id: 'WIN', expr: 'score >= 5', priority: 0 },
{ event_type: 'branch', target_step_id: 'LOSE', priority: 1 },
] },
{ id: 'WIN' },
{ id: 'LOSE' },
]),
);
runner.runStep('S1');
expect(runner.nextStep('S1')).toBe('LOSE');
});
});
describe('ScenarioRunner — firmware driver integration', () => {
it('pushes scenario and arms entry step on start()', async () => {
const driver = stubDriver();
const runner = new ScenarioRunner(
ir([
{ id: 'S1', puzzle: { id: 1, type: 'sound', melody: [60], fragment: [1] },
transitions: [{ event_type: 'unlock', target_step_id: 'S2' }] },
{ id: 'S2' },
]),
{ driver },
);
await runner.start();
expect(driver.calls).toContain('push:TEST');
expect(driver.calls).toContain('arm:S1');
});
it('advance() waits for puzzle solved then moves to next step', async () => {
const driver = stubDriver({ solved: true });
const runner = new ScenarioRunner(
ir([
{ id: 'S1', puzzle: { id: 1, type: 'sound', melody: [60], fragment: [1] },
transitions: [{ event_type: 'unlock', target_step_id: 'S2' }] },
{ id: 'S2' },
]),
{ driver, pollIntervalMs: 1 },
);
await runner.start();
const moved = await runner.advance();
expect(moved).toBe('S2');
expect(runner.currentStepId()).toBe('S2');
expect(driver.calls).toContain('arm:S2');
});
it('advance() polls puzzle_state until solved', async () => {
let n = 0;
const driver: FirmwareDriver & { calls: string[] } = {
calls: [],
async pushScenario() {},
async armStep(s) { this.calls.push(`arm:${s}`); },
async getPuzzleState() {
n++;
return { solved: n >= 3, assembled_code: '' };
},
async setGroupProfile() {},
};
const runner = new ScenarioRunner(
ir([
{ id: 'S1', puzzle: { id: 1, type: 'qr', codes: ['x'], fragment: [1] },
transitions: [{ event_type: 'unlock', target_step_id: 'S2' }] },
{ id: 'S2' },
]),
{ driver, pollIntervalMs: 1 },
);
await runner.start();
await runner.advance();
expect(n).toBeGreaterThanOrEqual(3);
expect(runner.currentStepId()).toBe('S2');
});
});
@@ -0,0 +1,367 @@
/**
* Evaluator for the Zacus "loose" condition grammar.
*
* The condition / loop `expr` strings are serialised by the Blocks Studio
* (`serializeReporter`, mirrored in the Python gateway) into a small grammar:
*
* - variables (identifiers, incl. the special `score`) — read from a scope
* - number literals (`5`, `3.5`) and text literals (`"oui"`, `'non'`)
* - comparisons: `==` `!=` `<` `<=` `>` `>=`
* - arithmetic: `+` `-` `*` `/` (and unary `-`)
* - booleans: `and` `or` `not`
* - parentheses for grouping
*
* This is a real parser (tokeniser + recursive descent) — NOT a JS `eval()`.
* It returns `number | string | boolean`. Helpers `evalBool` / `evalNumber`
* coerce to the type a caller needs.
*
* Grammar (lowest to highest precedence):
* expr := orExpr
* orExpr := andExpr ( "or" andExpr )*
* andExpr := notExpr ( "and" notExpr )*
* notExpr := "not" notExpr | comparison
* comparison := add ( ("=="|"!="|"<"|"<="|">"|">=") add )?
* add := mul ( ("+"|"-") mul )*
* mul := unary ( ("*"|"/") unary )*
* unary := "-" unary | primary
* primary := number | string | identifier | "(" expr ")"
*/
export type ExprScope = Record<string, number | string | boolean>;
export type ExprValue = number | string | boolean;
export class ExprError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExprError';
}
}
// ---------------------------------------------------------------------------
// Tokeniser
// ---------------------------------------------------------------------------
type TokenType =
| 'number'
| 'string'
| 'ident'
| 'op' // arithmetic / comparison operator
| 'kw' // and / or / not
| 'lparen'
| 'rparen';
interface Token {
type: TokenType;
value: string;
num?: number;
}
const KEYWORDS = new Set(['and', 'or', 'not']);
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const n = input.length;
while (i < n) {
const c = input[i]!;
// whitespace
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
i++;
continue;
}
// parentheses
if (c === '(') {
tokens.push({ type: 'lparen', value: '(' });
i++;
continue;
}
if (c === ')') {
tokens.push({ type: 'rparen', value: ')' });
i++;
continue;
}
// string literal — double or single quoted
if (c === '"' || c === "'") {
const quote = c;
let j = i + 1;
let str = '';
while (j < n && input[j] !== quote) {
if (input[j] === '\\' && j + 1 < n) {
str += input[j + 1];
j += 2;
} else {
str += input[j];
j++;
}
}
if (j >= n) throw new ExprError(`unterminated string literal in "${input}"`);
tokens.push({ type: 'string', value: str });
i = j + 1;
continue;
}
// number literal
if (isDigit(c) || (c === '.' && i + 1 < n && isDigit(input[i + 1]!))) {
let j = i;
while (j < n && (isDigit(input[j]!) || input[j] === '.')) j++;
const raw = input.slice(i, j);
const num = Number(raw);
if (Number.isNaN(num)) throw new ExprError(`invalid number "${raw}"`);
tokens.push({ type: 'number', value: raw, num });
i = j;
continue;
}
// multi-char comparison / single-char operators
const two = input.slice(i, i + 2);
if (two === '==' || two === '!=' || two === '<=' || two === '>=') {
tokens.push({ type: 'op', value: two });
i += 2;
continue;
}
if (c === '<' || c === '>' || c === '+' || c === '-' || c === '*' || c === '/') {
tokens.push({ type: 'op', value: c });
i++;
continue;
}
if (c === '=' || c === '!') {
// single '=' or '!' are not valid on their own in this grammar
throw new ExprError(`unexpected '${c}' in "${input}"`);
}
// identifier / keyword
if (isIdentStart(c)) {
let j = i;
while (j < n && isIdentPart(input[j]!)) j++;
const word = input.slice(i, j);
if (KEYWORDS.has(word)) {
tokens.push({ type: 'kw', value: word });
} else {
tokens.push({ type: 'ident', value: word });
}
i = j;
continue;
}
throw new ExprError(`unexpected character '${c}' in "${input}"`);
}
return tokens;
}
function isDigit(c: string): boolean {
return c >= '0' && c <= '9';
}
function isIdentStart(c: string): boolean {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
}
function isIdentPart(c: string): boolean {
return isIdentStart(c) || isDigit(c);
}
// ---------------------------------------------------------------------------
// Parser (recursive descent) — parses & evaluates in one pass.
// ---------------------------------------------------------------------------
class Parser {
private pos = 0;
constructor(
private readonly tokens: Token[],
private readonly scope: ExprScope,
private readonly source: string,
) {}
parse(): ExprValue {
if (this.tokens.length === 0) {
throw new ExprError(`empty expression "${this.source}"`);
}
const value = this.parseOr();
if (this.pos < this.tokens.length) {
throw new ExprError(
`unexpected token '${this.peek()!.value}' after expression in "${this.source}"`,
);
}
return value;
}
private peek(): Token | undefined {
return this.tokens[this.pos];
}
private next(): Token {
const t = this.tokens[this.pos];
if (!t) throw new ExprError(`unexpected end of expression in "${this.source}"`);
this.pos++;
return t;
}
private parseOr(): ExprValue {
let left = this.parseAnd();
while (this.peek()?.type === 'kw' && this.peek()!.value === 'or') {
this.next();
const right = this.parseAnd();
left = toBool(left) || toBool(right);
}
return left;
}
private parseAnd(): ExprValue {
let left = this.parseNot();
while (this.peek()?.type === 'kw' && this.peek()!.value === 'and') {
this.next();
const right = this.parseNot();
left = toBool(left) && toBool(right);
}
return left;
}
private parseNot(): ExprValue {
if (this.peek()?.type === 'kw' && this.peek()!.value === 'not') {
this.next();
return !toBool(this.parseNot());
}
return this.parseComparison();
}
private parseComparison(): ExprValue {
const left = this.parseAdd();
const t = this.peek();
if (
t?.type === 'op' &&
(t.value === '==' ||
t.value === '!=' ||
t.value === '<' ||
t.value === '<=' ||
t.value === '>' ||
t.value === '>=')
) {
this.next();
const right = this.parseAdd();
return compare(t.value, left, right);
}
return left;
}
private parseAdd(): ExprValue {
let left = this.parseMul();
while (this.peek()?.type === 'op' && (this.peek()!.value === '+' || this.peek()!.value === '-')) {
const op = this.next().value;
const right = this.parseMul();
left = op === '+' ? toNum(left) + toNum(right) : toNum(left) - toNum(right);
}
return left;
}
private parseMul(): ExprValue {
let left = this.parseUnary();
while (this.peek()?.type === 'op' && (this.peek()!.value === '*' || this.peek()!.value === '/')) {
const op = this.next().value;
const right = this.parseUnary();
left = op === '*' ? toNum(left) * toNum(right) : toNum(left) / toNum(right);
}
return left;
}
private parseUnary(): ExprValue {
if (this.peek()?.type === 'op' && this.peek()!.value === '-') {
this.next();
return -toNum(this.parseUnary());
}
return this.parsePrimary();
}
private parsePrimary(): ExprValue {
const t = this.next();
switch (t.type) {
case 'number':
return t.num!;
case 'string':
return t.value;
case 'ident': {
const v = this.scope[t.value];
// Loose grammar: an unknown variable resolves to 0.
return v === undefined ? 0 : v;
}
case 'lparen': {
const v = this.parseOr();
const close = this.peek();
if (!close || close.type !== 'rparen') {
throw new ExprError(`missing ')' in "${this.source}"`);
}
this.next();
return v;
}
default:
throw new ExprError(`unexpected token '${t.value}' in "${this.source}"`);
}
}
}
// ---------------------------------------------------------------------------
// Value coercion helpers
// ---------------------------------------------------------------------------
function toBool(v: ExprValue): boolean {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v !== 0;
return v.length > 0 && v !== 'false' && v !== '0';
}
function toNum(v: ExprValue): number {
if (typeof v === 'number') return v;
if (typeof v === 'boolean') return v ? 1 : 0;
const n = Number(v);
return Number.isNaN(n) ? 0 : n;
}
function compare(op: string, left: ExprValue, right: ExprValue): boolean {
// Equality is type-aware: compare strings as strings, numbers as numbers.
if (op === '==' || op === '!=') {
let eq: boolean;
if (typeof left === 'string' || typeof right === 'string') {
eq = String(left) === String(right);
} else {
eq = toNum(left) === toNum(right);
}
return op === '==' ? eq : !eq;
}
// Ordering comparisons are numeric.
const l = toNum(left);
const r = toNum(right);
switch (op) {
case '<':
return l < r;
case '<=':
return l <= r;
case '>':
return l > r;
case '>=':
return l >= r;
default:
throw new ExprError(`unknown comparison operator '${op}'`);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Parse and evaluate an expression, returning number | string | boolean. */
export function evalExpr(expr: string, scope: ExprScope = {}): ExprValue {
const tokens = tokenize(expr);
return new Parser(tokens, scope, expr).parse();
}
/** Evaluate and coerce to boolean (used for conditions / while-loops). */
export function evalBool(expr: string, scope: ExprScope = {}): boolean {
return toBool(evalExpr(expr, scope));
}
/** Evaluate and coerce to number. */
export function evalNumber(expr: string, scope: ExprScope = {}): number {
return toNum(evalExpr(expr, scope));
}
@@ -0,0 +1,100 @@
/**
* Firmware driver — drives the ESP32 master over HTTP.
*
* The firmware does NOT execute the scenario; it is piloted from the outside.
* The runner uses this interface to: push the IR, arm a step's local puzzle,
* poll the resolved state, and set the detected group profile.
*
* Endpoints (master):
* POST /game/scenario — load the IR
* POST /game/step — arm the puzzle for { step_id }
* GET /game/puzzle_state — current resolution state
* POST /game/group_profile — set the detected group profile
*
* `fetch` is injected for testability (no real network calls in tests).
*/
import type { RuntimeIR } from './runtime-ir.js';
/** Snapshot returned by GET /game/puzzle_state. Extra fields tolerated. */
export interface PuzzleState {
/** True once the current step's puzzle is resolved. */
solved: boolean;
/** Code assembled so far from solved fragments. */
assembled_code?: string;
[key: string]: unknown;
}
/** Group profile payload for /game/group_profile. */
export interface GroupProfilePayload {
profile?: string;
[key: string]: unknown;
}
/** Abstraction the runner depends on; HTTP impl + test stubs both satisfy it. */
export interface FirmwareDriver {
pushScenario(ir: RuntimeIR): Promise<void>;
armStep(stepId: string): Promise<void>;
getPuzzleState(): Promise<PuzzleState>;
setGroupProfile(profile: GroupProfilePayload): Promise<void>;
}
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export interface HttpFirmwareDriverOptions {
/** Injected fetch (defaults to global fetch). */
fetch?: FetchLike;
}
export class HttpFirmwareDriver implements FirmwareDriver {
private readonly baseUrl: string;
private readonly fetch: FetchLike;
constructor(baseUrl: string, options: HttpFirmwareDriverOptions = {}) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
const injected = options.fetch;
if (injected) {
this.fetch = injected;
} else if (typeof globalThis.fetch === 'function') {
this.fetch = globalThis.fetch.bind(globalThis);
} else {
throw new Error('HttpFirmwareDriver: no fetch available; inject one via options.fetch');
}
}
async pushScenario(ir: RuntimeIR): Promise<void> {
await this.post('/game/scenario', ir);
}
async armStep(stepId: string): Promise<void> {
await this.post('/game/step', { step_id: stepId });
}
async getPuzzleState(): Promise<PuzzleState> {
const res = await this.fetch(`${this.baseUrl}/game/puzzle_state`, { method: 'GET' });
this.assertOk(res, 'GET /game/puzzle_state');
return (await res.json()) as PuzzleState;
}
async setGroupProfile(profile: GroupProfilePayload): Promise<void> {
await this.post('/game/group_profile', profile);
}
// -------------------------------------------------------------------------
private async post(path: string, body: unknown): Promise<Response> {
const res = await this.fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
this.assertOk(res, `POST ${path}`);
return res;
}
private assertOk(res: Response, what: string): void {
if (!res.ok) {
throw new Error(`firmware ${what} failed: HTTP ${res.status}`);
}
}
}
@@ -3,3 +3,34 @@ export { detectGroupProfile, profileConfidence } from './profiler.js';
export { computeScore, assembleCode } from './scorer.js';
// Re-export the interface so consumers can type-check against it
export type { ScenarioEngine } from '@zacus/shared';
// Runtime 3 runner — makes the IR playable (expressions, loops, transitions).
export { evalExpr, evalBool, evalNumber, ExprError } from './expr-eval.js';
export type { ExprScope, ExprValue } from './expr-eval.js';
export { ScenarioRunner } from './runner.js';
export type { RunnerOptions } from './runner.js';
export { HttpFirmwareDriver } from './firmware-driver.js';
export type {
FirmwareDriver,
PuzzleState,
GroupProfilePayload,
HttpFirmwareDriverOptions,
} from './firmware-driver.js';
export type {
RuntimeIR,
RuntimeScenarioMeta,
RuntimeStep,
RuntimeTransition,
RuntimeAction,
ActionObject,
ScoreAddAction,
SetVarAction,
ConditionAction,
LoopAction,
StepPuzzle,
StepScene,
} from './runtime-ir.js';
export { isActionObject, actionKind } from './runtime-ir.js';
@@ -0,0 +1,293 @@
/**
* ScenarioRunner — makes a Runtime 3 IR actually *playable*.
*
* Unlike the legacy `ZacusScenarioEngine` (a phase/score simulator that left
* conditions as opaque strings), the runner:
* - executes each step's action list, including the `condition` (then/else)
* and `loop` (repeat/while) control kinds, evaluating their `expr` with the
* loose-grammar evaluator;
* - maintains a variable scope (incl. `score`) mutated by `score_add` /
* `set_var`, and used to evaluate transition conditions;
* - resolves the next step from a step's transitions, honouring `branch` /
* `condition` transition `expr` and `priority`;
* - when a `FirmwareDriver` is provided, pilots the master live: pushes the
* IR, arms each step, and polls `puzzle_state` until the step is resolved
* before advancing.
*
* Non-control actions (tts_say, hw_servo, …) are forwarded to an `onAction`
* sink so a host (firmware adapter, simulator UI) can realise them.
*/
import { evalBool, evalNumber, type ExprScope } from './expr-eval.js';
import {
type RuntimeIR,
type RuntimeStep,
type RuntimeAction,
type RuntimeTransition,
isActionObject,
} from './runtime-ir.js';
import type { FirmwareDriver, GroupProfilePayload } from './firmware-driver.js';
export interface RunnerOptions {
/** Live firmware driver. If absent, the runner executes logic only. */
driver?: FirmwareDriver;
/** Sink for non-control actions (tts_say, hw_servo, led_pattern, …). */
onAction?: (action: RuntimeAction, scope: Readonly<ExprScope>) => void;
/** Upper bound on loop iterations to prevent runaway while-loops. */
maxLoopIterations?: number;
/** Poll interval (ms) when waiting for a puzzle to resolve. */
pollIntervalMs?: number;
/** Max polls before giving up waiting for resolution (0 = unbounded). */
maxPolls?: number;
/** Injected sleep, for testability. */
sleep?: (ms: number) => Promise<void>;
}
const DEFAULT_MAX_LOOP_ITERATIONS = 10_000;
const DEFAULT_POLL_INTERVAL_MS = 250;
interface ResolvedOptions {
driver?: FirmwareDriver;
onAction?: (action: RuntimeAction, scope: Readonly<ExprScope>) => void;
maxLoopIterations: number;
pollIntervalMs: number;
maxPolls: number;
sleep: (ms: number) => Promise<void>;
}
export class ScenarioRunner {
private readonly ir: RuntimeIR;
private readonly steps: Map<string, RuntimeStep>;
private readonly opts: ResolvedOptions;
private scope: ExprScope = { score: 0 };
private current: string;
constructor(ir: RuntimeIR, options: RunnerOptions = {}) {
this.ir = ir;
this.steps = new Map(ir.steps.map((s) => [s.id, s]));
this.current = ir.scenario.entry_step_id;
this.opts = {
maxLoopIterations: options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS,
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
maxPolls: options.maxPolls ?? 0,
sleep: options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))),
};
if (options.driver) this.opts.driver = options.driver;
if (options.onAction) this.opts.onAction = options.onAction;
}
// ---------------------------------------------------------------- scope ----
getVar(name: string): number | string | boolean | undefined {
return this.scope[name];
}
setVar(name: string, value: number | string | boolean): void {
this.scope[name] = value;
}
getScope(): Readonly<ExprScope> {
return { ...this.scope };
}
currentStepId(): string {
return this.current;
}
// ------------------------------------------------------------- execution ----
/** Execute all actions of a step (logic only — no firmware/transition). */
runStep(stepId: string): void {
const step = this.requireStep(stepId);
this.runActions(step.actions ?? []);
}
private runActions(actions: RuntimeAction[]): void {
for (const action of actions) {
this.runAction(action);
}
}
private runAction(action: RuntimeAction): void {
if (!isActionObject(action)) {
// String-form action (e.g. "led_pattern:rainbow") — opaque, forward it.
this.opts.onAction?.(action, this.scope);
return;
}
switch (action.kind) {
case 'score_add': {
const delta = this.numField(action['delta']);
this.scope['score'] = this.numScope('score') + delta;
return;
}
case 'set_var': {
const name = String(action['name'] ?? '');
if (name) this.scope[name] = String(action['value'] ?? '');
return;
}
case 'condition': {
const expr = String(action['expr'] ?? '');
const branch = this.evalBoolSafe(expr)
? (action['then'] as RuntimeAction[] | undefined)
: (action['else'] as RuntimeAction[] | undefined);
if (Array.isArray(branch)) this.runActions(branch);
return;
}
case 'loop': {
this.runLoop(action);
return;
}
default:
// Non-control action: forward to the host sink.
this.opts.onAction?.(action, this.scope);
return;
}
}
private runLoop(action: { [key: string]: unknown }): void {
const mode = action['mode'];
const body = Array.isArray(action['body']) ? (action['body'] as RuntimeAction[]) : [];
const limit = this.opts.maxLoopIterations;
if (mode === 'repeat') {
const times = Math.max(0, Math.floor(this.numField(action['times'])));
const n = Math.min(times, limit);
for (let i = 0; i < n; i++) this.runActions(body);
if (times > limit) {
throw new Error(`loop iteration limit exceeded (${limit}) for repeat loop`);
}
return;
}
if (mode === 'while') {
const expr = String(action['expr'] ?? '');
let i = 0;
while (this.evalBoolSafe(expr)) {
if (i >= limit) {
throw new Error(`loop iteration limit exceeded (${limit}) for while loop`);
}
this.runActions(body);
i++;
}
return;
}
throw new Error(`unknown loop mode '${String(mode)}'`);
}
// ----------------------------------------------------------- transitions ----
/**
* Resolve the next step id from a step's transitions.
* Branch/condition transitions are taken only when their `expr` is truthy
* (a transition with no `expr` is always eligible). Lower `priority` wins.
* Returns undefined when no transition applies (terminal step).
*/
nextStep(stepId: string): string | undefined {
const step = this.requireStep(stepId);
const transitions = [...(step.transitions ?? [])].sort(
(a, b) => (a.priority ?? 0) - (b.priority ?? 0),
);
for (const t of transitions) {
if (this.transitionEligible(t)) return t.target_step_id;
}
return undefined;
}
private transitionEligible(t: RuntimeTransition): boolean {
if (typeof t.expr === 'string' && t.expr.length > 0) {
return this.evalBoolSafe(t.expr);
}
// branch/condition transitions without an expr act as the default fallback;
// all other event types are eligible (firmware/timer gates them at advance).
return true;
}
// -------------------------------------------------------- live firmware ----
/** Push the IR to the firmware and arm the entry step. */
async start(): Promise<void> {
this.current = this.ir.scenario.entry_step_id;
if (this.opts.driver) {
await this.opts.driver.pushScenario(this.ir);
}
await this.enterStep(this.current);
}
/** Run actions for a step and (if live) arm its puzzle on the firmware. */
private async enterStep(stepId: string): Promise<void> {
this.current = stepId;
this.runStep(stepId);
if (this.opts.driver) {
await this.opts.driver.armStep(stepId);
}
}
/**
* Advance from the current step: wait for the firmware to resolve the step's
* puzzle (polling `puzzle_state`), then move to the next step (entering it).
* Returns the new step id, or undefined when the scenario is terminal.
*/
async advance(): Promise<string | undefined> {
const step = this.requireStep(this.current);
if (this.opts.driver && step.puzzle) {
await this.waitForResolution();
}
const next = this.nextStep(this.current);
if (next === undefined) return undefined;
await this.enterStep(next);
return next;
}
private async waitForResolution(): Promise<void> {
const driver = this.opts.driver!;
let polls = 0;
for (;;) {
const state = await driver.getPuzzleState();
if (state.solved) {
if (typeof state.assembled_code === 'string') {
this.scope['assembled_code'] = state.assembled_code;
}
return;
}
polls++;
if (this.opts.maxPolls > 0 && polls >= this.opts.maxPolls) {
throw new Error(`puzzle_state not resolved after ${polls} polls`);
}
await this.opts.sleep(this.opts.pollIntervalMs);
}
}
/** Forward a detected group profile to the firmware. */
async setGroupProfile(profile: GroupProfilePayload): Promise<void> {
if (this.opts.driver) await this.opts.driver.setGroupProfile(profile);
}
// ----------------------------------------------------------------- utils ----
private requireStep(stepId: string): RuntimeStep {
const step = this.steps.get(stepId);
if (!step) throw new Error(`unknown step '${stepId}'`);
return step;
}
private evalBoolSafe(expr: string): boolean {
if (!expr) return false;
return evalBool(expr, this.scope);
}
private numField(v: unknown): number {
if (typeof v === 'number') return v;
if (typeof v === 'string') {
const n = Number(v);
return Number.isNaN(n) ? evalNumber(v, this.scope) : n;
}
return 0;
}
private numScope(name: string): number {
const v = this.scope[name];
return typeof v === 'number' ? v : v === undefined ? 0 : Number(v) || 0;
}
}
@@ -0,0 +1,138 @@
/**
* TypeScript model of the Zacus Runtime 3 IR (`zacus.runtime3.v1`).
*
* Mirrors the document produced by the Python compiler
* (`tools/scenario/runtime3_common.py`) and consumed by the ESP32 master
* firmware. Only the parts the runner needs are typed precisely; unknown
* fields are tolerated (the IR is passed through verbatim by the compiler).
*
* See `specs/ZACUS_RUNTIME_3_SPEC.md`.
*/
export interface RuntimeIR {
schema_version: string; // "zacus.runtime3.v1"
scenario: RuntimeScenarioMeta;
steps: RuntimeStep[];
metadata?: Record<string, unknown>;
}
export interface RuntimeScenarioMeta {
id: string;
version: number;
title: string;
entry_step_id: string;
source_kind?: string;
}
export interface RuntimeStep {
id: string;
scene_id?: string;
audio_pack_id?: string;
actions?: RuntimeAction[];
apps?: unknown[];
transitions?: RuntimeTransition[];
narrative?: string;
/** Master-local puzzle armed on step entry (P1 sound / P3 QR). */
puzzle?: StepPuzzle;
/** Master display metadata. */
scene?: StepScene;
}
// ---------------------------------------------------------------------------
// Transitions
// ---------------------------------------------------------------------------
/**
* `event_type` is an opaque runtime contract. The runner understands:
* - `branch` / `condition`: pick target when `expr` is truthy (see runner)
* - `timer`: fires after `after_ms`
* - `action` with `event_name` `goto:<target>`: explicit jump
* - everything else (`button`, `serial`, `unlock`, `espnow`, `audio_done`):
* advanced when the firmware reports the step resolved.
*/
export interface RuntimeTransition {
event_type: string;
event_name?: string;
target_step_id: string;
priority?: number;
after_ms?: number;
/** Loose-grammar condition. Present on `branch` / `condition` transitions. */
expr?: string;
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
/** Dict-form action. String-form (`"led_pattern:rainbow"`) is also accepted. */
export type RuntimeAction = ActionObject | string;
export interface ActionObject {
kind: string;
[key: string]: unknown;
}
export interface ScoreAddAction extends ActionObject {
kind: 'score_add';
delta: number;
}
export interface SetVarAction extends ActionObject {
kind: 'set_var';
name: string;
value: string;
}
export interface ConditionAction extends ActionObject {
kind: 'condition';
expr: string;
then?: RuntimeAction[];
else?: RuntimeAction[];
}
/** Vague 1: bounded `repeat` (times) / `while` (expr) loop with a nested body. */
export interface LoopAction extends ActionObject {
kind: 'loop';
mode: 'repeat' | 'while';
times?: number; // repeat mode
expr?: string; // while mode
body?: RuntimeAction[];
}
// ---------------------------------------------------------------------------
// Step extras
// ---------------------------------------------------------------------------
export interface StepPuzzle {
id: number;
type: 'qr' | 'sound';
codes?: string[];
melody?: number[];
tolerance?: number;
fragment: number[];
scene?: string;
}
export interface StepScene {
title?: string;
subtitle?: string;
symbol?: string;
effect?: 'pulse' | 'glitch' | 'gyro' | 'none';
}
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
export function isActionObject(a: RuntimeAction): a is ActionObject {
return typeof a === 'object' && a !== null && typeof a.kind === 'string';
}
export function actionKind(a: RuntimeAction): string {
if (typeof a === 'string') {
// String form: "led_pattern:rainbow" -> kind "led_pattern".
const idx = a.indexOf(':');
return idx >= 0 ? a.slice(0, idx) : a;
}
return a.kind;
}
@@ -0,0 +1,28 @@
// Hand-written types for the canonical Blockly catalog (plain .mjs so the
// hub's classic-script copy can be generated from the same source).
export declare const ZACUS_COLOR: Record<string, string>;
export declare const ZACUS_VOICES: ReadonlyArray<[string, string]>;
export declare const ZACUS_EMOTIONS: ReadonlyArray<[string, string]>;
export declare const ZACUS_SCENE_EFFECTS: ReadonlyArray<[string, string]>;
export declare const ZACUS_COMPARE_OPS: ReadonlyArray<[string, string]>;
export declare const ZACUS_BOOL_OPS: ReadonlyArray<[string, string]>;
export declare const ZACUS_ARITH_OPS: ReadonlyArray<[string, string]>;
export declare const ZACUS_PHASE_TRIGGERS: ReadonlyArray<[string, string]>;
export declare const ZACUS_NPC_MOODS: ReadonlyArray<[string, string]>;
export declare const ZACUS_GM_ACTIONS: ReadonlyArray<[string, string]>;
export declare const ZACUS_PENALTY_KINDS: ReadonlyArray<[string, string]>;
export declare const ZACUS_MORSE_MODES: ReadonlyArray<[string, string]>;
/** Register every zacus_* block shape against the given Blockly module. */
export declare function registerZacusBlocks(blockly: unknown): void;
export declare const ZACUS_BLOCK_TYPES: readonly string[];
export declare const ZACUS_FIELDS_BY_KIND: Readonly<Record<string, readonly string[]>>;
export interface ZacusToolboxCategory {
name: string;
colour: string;
types: readonly string[];
}
export declare const ZACUS_TOOLBOX_CATEGORIES: readonly ZacusToolboxCategory[];
@@ -0,0 +1,847 @@
// Canonical Zacus Blockly catalog — single source of truth for BOTH editors:
// - Atelier webapp: import { registerZacusBlocks } from '@zacus/shared/blockly'
// - zacus-hub WebView: apps/zacus-hub/Resources/blockly/zacus_blocks.js is
// GENERATED from this file by tools/dev/sync_blockly_catalog.sh (strips
// `export` keywords and appends the legacy window.* globals + auto-register).
// Never edit the hub copy by hand.
//
// Block kinds match tools/zacus-gateway/blocks_to_runtime3.py (without the
// `zacus_` prefix used for the Blockly type names). Shapes follow Scratch
// conventions: hat (sceneStart), cap (sceneEnd), statement (most), c-block
// (logicIf with "alors"/"sinon" slots).
export const ZACUS_COLOR = {
scene: "#5085F2",
npc: "#D85B73",
audio: "#A772D9",
lcd: "#338CBF",
hardware: "#2EAD8C",
espnow: "#F28033",
box3: "#8C5A33",
m5: "#4D73A6",
plip: "#C74D8C",
logic: "#F2A633",
puzzle: "#59A86C",
operator: "#59A659",
control: "#D9A032",
gm: "#7A5CCC",
};
// Vague 1 — opérateurs de comparaison (sérialisés en string d'expression
// dans la grammaire « lâche » déjà utilisée par les conditions du scénario,
// ex `score > 5`). value = symbole émis dans la string.
export const ZACUS_COMPARE_OPS = [
["=", "=="], ["≠", "!="], ["<", "<"], ["≤", "<="], [">", ">"], ["≥", ">="],
];
export const ZACUS_BOOL_OPS = [
["et", "and"], ["ou", "or"],
];
export const ZACUS_ARITH_OPS = [
["+", "+"], ["", "-"], ["×", "*"], ["÷", "/"],
];
// Effets d'affichage de la vue scène du maître (puzzle_binding.h).
export const ZACUS_SCENE_EFFECTS = [
["pulse", "pulse"],
["glitch", "glitch"],
["gyro", "gyro"],
["aucun", "none"],
];
// « Zacus dit » voice options. `voice` maps to the voice-bridge `voice_ref`
// (reference clip for F5-TTS voice cloning); emotion + rate are stored in the
// IR (tts_say) as forward-compat fields — the bridge ignores them until it
// learns them.
export const ZACUS_VOICES = [
["Zacus", "zacus"],
["Narrateur", "narrateur"],
["Assistant", "assistant"],
];
export const ZACUS_EMOTIONS = [
["neutre", "neutral"],
["joyeux", "happy"],
["inquiet", "worried"],
["mystérieux", "mysterious"],
["pressé", "urgent"],
];
// ── Vague 2 : couche « Maître du jeu » (game-master adaptatif) ─────────
// Vocabulaire repris de game/scenarios/zacus_v3_complete.yaml (phases,
// npc_mood_rules, hint_escalation, scoring conditionnel, group_classification).
// Déclencheurs de phase (phases.*.trigger). game_start est le seul présent
// dans le scénario ; les autres couvrent l'enchaînement profiling→outro.
export const ZACUS_PHASE_TRIGGERS = [
["démarrage partie", "game_start"],
["phase précédente finie", "phase_done"],
["profilage terminé", "profiling_done"],
["manuel", "manual"],
];
// Humeurs NPC adaptatives (npc_mood_rules.*.mood).
export const ZACUS_NPC_MOODS = [
["neutre", "NEUTRAL"],
["impressionné", "IMPRESSED"],
["inquiet", "WORRIED"],
["pressé", "URGENT"],
["amusé", "AMUSED"],
];
// Actions adaptatives (npc_mood_rules.*.action + game-master loop).
export const ZACUS_GM_ACTIONS = [
["indice proactif", "proactive_hint"],
["passer au suivant", "skip_to_next"],
["ajouter énigme bonus", "add_bonus_puzzle"],
];
// Types de pénalité de score (scoring.*).
export const ZACUS_PENALTY_KINDS = [
["par minute", "time_penalty_per_minute"],
["par indice", "hint_penalty"],
];
// ── Vagues 3 & 4 : énigmes manquantes + assemblage du code final ──────
// Vocabulaire repris de game/scenarios/zacus_v3_complete.yaml :
// - P2 Circuit LED, P4 Fréquence Radio, P5 Code Morse, P6 Symboles NFC,
// P7 Coffre Final (puzzles.*),
// - phases.climax.code_assembly (mapping puzzle→position de chiffres).
// Mode du décodage morse (P5.difficulty_variants : full_morse_decode vs
// morse_light_mode où l'émission est lumineuse au lieu d'être sonore).
export const ZACUS_MORSE_MODES = [
["sonore (normal)", "normal"],
["lumineux (light)", "light"],
];
// ── Phase D2 : binding carte ──────────────────────────────────────────
// Cartes connues = clés de tools/zacus-gateway/boards.yaml. Le champ `board`
// (défaut `master`) sur les énigmes réelles et le bloc contexte `hwOnBoard`
// ciblent l'exécution sur une carte précise. Garde cette liste en miroir de
// VALID_BOARDS côté Python (tools/scenario/runtime3_common.py) et de boards.yaml.
export const ZACUS_BOARDS = [
["Master", "master"],
["BOX-3", "box3"],
["PLIP", "plip"],
["Coffre P7", "p7_coffre"],
];
export function registerZacusBlocks(Blockly) {
function def(name, init) { Blockly.Blocks[name] = { init: init }; }
function stack(t, color) { t.setPreviousStatement(true, null); t.setNextStatement(true, null); t.setColour(color); }
function dropdown(opts) {
// accept [["label","value"], …] or ["value", …]
return new Blockly.FieldDropdown(opts.map(o => Array.isArray(o) ? o : [o, o]));
}
// Blockly >=10 ships FieldMultilineInput as a plugin, not core. Fall back to
// a plain text input so the catalog works against both bundles.
const MultilineInput = Blockly.FieldMultilineInput || Blockly.FieldTextInput;
// Scènes
def("zacus_sceneStart", function () {
this.appendDummyInput().appendField("▶ Début de scène").appendField(new Blockly.FieldTextInput("intro"), "id");
this.setNextStatement(true, null); this.setColour(ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_sceneEnd", function () {
this.appendDummyInput().appendField("⏹ Fin de scène");
this.setPreviousStatement(true, null); this.setColour(ZACUS_COLOR.scene);
});
def("zacus_sceneGoto", function () {
this.appendDummyInput().appendField("Aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_sceneBranch", function () {
// condition : soit un reporter branché (appendValueInput "condition"),
// soit le champ texte de repli (rétrocompat strings opaques).
this.appendValueInput("condition").setCheck(null).appendField("Branche si");
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("score > 5"), "condition");
this.appendDummyInput().appendField("alors").appendField(new Blockly.FieldTextInput("scene_a"), "ifTrue");
this.appendDummyInput().appendField("sinon").appendField(new Blockly.FieldTextInput("scene_b"), "ifFalse");
stack(this, ZACUS_COLOR.scene);
});
// NPC — « Zacus dit » porte la voix (voice_ref), l'émotion et la vitesse.
def("zacus_npcSay", function () {
this.appendDummyInput().appendField("Zacus dit")
.appendField(new MultilineInput("Bonjour, je suis le professeur Zacus."), "text");
this.appendDummyInput().appendField("voix").appendField(dropdown(ZACUS_VOICES), "voice")
.appendField("émotion").appendField(dropdown(ZACUS_EMOTIONS), "emotion")
.appendField("vitesse").appendField(new Blockly.FieldNumber(1, 0.5, 2, 0.1), "rate");
stack(this, ZACUS_COLOR.npc);
});
def("zacus_npcWaitResponse", function () {
this.appendDummyInput().appendField("Attendre réponse — timeout")
.appendField(new Blockly.FieldNumber(10, 1, 120, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.npc); this.setInputsInline(true);
});
def("zacus_npcIntentMatch", function () {
this.appendDummyInput().appendField("Si intent =").appendField(new Blockly.FieldTextInput("yes"), "intent");
this.appendDummyInput().appendField("alors aller à").appendField(new Blockly.FieldTextInput("scene_x"), "then");
stack(this, ZACUS_COLOR.npc);
});
// Audio
def("zacus_hwSoundPlay", function () {
this.appendDummyInput().appendField("🔊 Jouer son").appendField(new Blockly.FieldTextInput("sting_win"), "asset");
stack(this, ZACUS_COLOR.audio); this.setInputsInline(true);
});
def("zacus_hwAudioStop", function () {
this.appendDummyInput().appendField("⏸ Stopper l'audio");
stack(this, ZACUS_COLOR.audio);
});
def("zacus_hwAudioVolume", function () {
this.appendDummyInput().appendField("Volume")
.appendField(new Blockly.FieldNumber(70, 0, 100, 1), "level").appendField("/100");
stack(this, ZACUS_COLOR.audio); this.setInputsInline(true);
});
// Phase D3 — média réel SD : joue un fichier par CHEMIN SD réel (ex.
// zacus/<board>/<file>.wav), résolu par le firmware (/sdcard/… ou
// /littlefs/…). Le `board` vient du contexte hwOnBoard (défaut master),
// PAS d'un champ ici. Compile → action sound_play{path, board, loop}. Le
// legacy zacus_hwSoundPlay(asset opaque) reste inchangé.
def("zacus_mediaPlay", function () {
this.appendDummyInput().appendField("♫ Jouer média")
.appendField(new Blockly.FieldTextInput("zacus/master/intro.wav"), "path");
this.appendDummyInput().appendField("boucle").appendField(new Blockly.FieldCheckbox("FALSE"), "loop");
stack(this, ZACUS_COLOR.audio); this.setInputsInline(true);
});
// LCD
def("zacus_hwLCDText", function () {
this.appendDummyInput().appendField("LCD ligne")
.appendField(new Blockly.FieldNumber(0, 0, 32, 1), "line")
.appendField("texte").appendField(new Blockly.FieldTextInput("Bienvenue"), "text");
stack(this, ZACUS_COLOR.lcd); this.setInputsInline(true);
});
def("zacus_hwLCDClear", function () {
this.appendDummyInput().appendField("LCD — effacer");
stack(this, ZACUS_COLOR.lcd);
});
def("zacus_hwLCDImage", function () {
this.appendDummyInput().appendField("LCD image").appendField(new Blockly.FieldTextInput("splash"), "asset");
stack(this, ZACUS_COLOR.lcd); this.setInputsInline(true);
});
def("zacus_hwLCDTouchWait", function () {
this.appendDummyInput().appendField("Attendre tap zone").appendField(new Blockly.FieldTextInput("btn_yes"), "zone")
.appendField("timeout").appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.lcd); this.setInputsInline(true);
});
// Hardware (ESP32 générique)
def("zacus_hwServo", function () {
this.appendDummyInput().appendField("Servo canal")
.appendField(new Blockly.FieldNumber(0, 0, 15, 1), "channel")
.appendField("→ angle").appendField(new Blockly.FieldNumber(90, 0, 180, 1), "angle").appendField("°");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
def("zacus_hwReadQR", function () {
this.appendDummyInput().appendField("QR — attendu").appendField(new Blockly.FieldTextInput("ZAC-A1"), "expected");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
def("zacus_hwLEDPattern", function () {
this.appendDummyInput().appendField("LED motif").appendField(dropdown([
["arc-en-ciel","rainbow"],["clignote","blink"],["fondu","fade"],["éteindre","off"]
]), "pattern");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
def("zacus_hwBuzzerTone", function () {
this.appendDummyInput().appendField("Buzzer")
.appendField(new Blockly.FieldNumber(2000, 20, 20000, 1), "freq").appendField("Hz pendant")
.appendField(new Blockly.FieldNumber(120, 1, 10000, 1), "ms").appendField("ms");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
def("zacus_hwRelay", function () {
this.appendDummyInput().appendField("Relais canal")
.appendField(new Blockly.FieldNumber(0, 0, 15, 1), "channel")
.appendField(dropdown([["allumer","on"],["éteindre","off"],["impulsion","pulse"]]), "state");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
def("zacus_hwSensorRead", function () {
this.appendDummyInput().appendField("Lire capteur").appendField(new Blockly.FieldTextInput("A0"), "pin")
.appendField("→").appendField(new Blockly.FieldTextInput("lecture"), "var");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
def("zacus_hwButtonWait", function () {
this.appendDummyInput().appendField("Attendre bouton").appendField(new Blockly.FieldTextInput("btn_main"), "button")
.appendField("timeout").appendField(new Blockly.FieldNumber(0, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
// ESP-NOW
def("zacus_espnowRegisterPeer", function () {
this.appendDummyInput().appendField("ESP-NOW alias").appendField(new Blockly.FieldTextInput("annexe1"), "alias")
.appendField("MAC").appendField(new Blockly.FieldTextInput("AA:BB:CC:DD:EE:FF"), "mac");
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
});
def("zacus_espnowSend", function () {
this.appendDummyInput().appendField("ESP-NOW → peer").appendField(new Blockly.FieldTextInput("annexe1"), "peer")
.appendField("cmd").appendField(new Blockly.FieldTextInput("open_door"), "command");
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
});
def("zacus_espnowBroadcast", function () {
this.appendDummyInput().appendField("ESP-NOW broadcast").appendField(new Blockly.FieldTextInput("reset"), "command");
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
});
def("zacus_espnowWait", function () {
this.appendDummyInput().appendField("ESP-NOW attendre").appendField(new Blockly.FieldTextInput("ready"), "command")
.appendField("timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
});
// ESP32-S3-BOX-3
def("zacus_boxIMUShake", function () {
this.appendDummyInput().appendField("BOX-3 secousse seuil")
.appendField(new Blockly.FieldNumber(1.5, 0.1, 10, 0.1), "threshold")
.appendField("g timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.box3); this.setInputsInline(true);
});
def("zacus_boxIRSend", function () {
this.appendDummyInput().appendField("BOX-3 IR").appendField(dropdown(["NEC","RC5","SONY","RAW"]), "protocol")
.appendField(new Blockly.FieldTextInput("0x20DF10EF"), "code");
stack(this, ZACUS_COLOR.box3); this.setInputsInline(true);
});
// M5Stack / M5StickC
def("zacus_m5Beep", function () {
this.appendDummyInput().appendField("M5 bip")
.appendField(new Blockly.FieldNumber(4000, 20, 20000, 1), "freq").appendField("Hz")
.appendField(new Blockly.FieldNumber(200, 1, 5000, 1), "ms").appendField("ms");
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
});
def("zacus_m5LCDText", function () {
this.appendDummyInput().appendField("M5 LCD").appendField(new Blockly.FieldTextInput("Hello"), "text")
.appendField(dropdown(["white","yellow","red","green","blue","cyan","magenta"]), "color")
.appendField("size").appendField(new Blockly.FieldNumber(2, 1, 8, 1), "size");
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
});
def("zacus_m5ButtonAB", function () {
this.appendDummyInput().appendField("M5 bouton").appendField(dropdown(["A","B","C","any"]), "button")
.appendField("timeout").appendField(new Blockly.FieldNumber(0, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
});
def("zacus_m5RGBLed", function () {
this.appendDummyInput().appendField("M5 LED RGB").appendField(new Blockly.FieldTextInput("#FF8800"), "color");
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
});
def("zacus_m5IMUShake", function () {
this.appendDummyInput().appendField("M5 secousse seuil")
.appendField(new Blockly.FieldNumber(1.5, 0.1, 10, 0.1), "threshold")
.appendField("g timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
});
// PLIP téléphone
def("zacus_plipRing", function () {
this.appendDummyInput().appendField("☎ PLIP sonner")
.appendField(new Blockly.FieldNumber(3, 0, 60, 0.1), "duration_s").appendField("s");
stack(this, ZACUS_COLOR.plip); this.setInputsInline(true);
});
def("zacus_plipPickupWait", function () {
this.appendDummyInput().appendField("☎ PLIP attendre décroché — timeout")
.appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.plip); this.setInputsInline(true);
});
// Énigmes locales du maître — ces blocs ne produisent pas d'actions : ils
// attachent les objets IR `scene` / `puzzle` au step courant (un seul de
// chaque par step, voir specs/ZACUS_RUNTIME_3_SPEC.md « Step extras »).
def("zacus_scene", function () {
this.appendDummyInput().appendField("🖥 Écran maître — titre")
.appendField(new Blockly.FieldTextInput("MISSION QR"), "title");
this.appendDummyInput().appendField("sous-titre")
.appendField(new Blockly.FieldTextInput("scannez dans l'ordre"), "subtitle");
this.appendDummyInput().appendField("symbole").appendField(new Blockly.FieldTextInput("RUN"), "symbol")
.appendField("effet").appendField(dropdown(ZACUS_SCENE_EFFECTS), "effect");
stack(this, ZACUS_COLOR.puzzle);
});
def("zacus_puzzleQR", function () {
this.appendDummyInput().appendField("🧩 Énigme QR — slot")
.appendField(new Blockly.FieldNumber(3, 1, 8, 1), "id");
this.appendDummyInput().appendField("codes (dans l'ordre, virgules)")
.appendField(new Blockly.FieldTextInput("zacus-qr-1, zacus-qr-2"), "codes");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("3"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
def("zacus_puzzleSound", function () {
this.appendDummyInput().appendField("🧩 Énigme son — slot")
.appendField(new Blockly.FieldNumber(1, 1, 8, 1), "id");
this.appendDummyInput().appendField("mélodie (notes MIDI, virgules)")
.appendField(new Blockly.FieldTextInput("60, 62, 64, 65"), "melody")
.appendField("tolérance").appendField(new Blockly.FieldNumber(1, 0, 12, 1), "tolerance");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("1"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
// ── Vague 3 : énigmes manquantes (P2/P4/P5/P6/P7) ─────────────────
// Même pattern que puzzleQR/puzzleSound : step-extra `puzzle` (pas une
// action), slot id 1-8, paramètres propres à l'énigme + fragment 1-4
// chiffres. Couleur ZACUS_COLOR.puzzle.
def("zacus_puzzleLED", function () {
this.appendDummyInput().appendField("🧩 Énigme circuit LED — slot")
.appendField(new Blockly.FieldNumber(2, 1, 8, 1), "id");
this.appendDummyInput().appendField("motif attendu (composants/états, virgules)")
.appendField(new Blockly.FieldTextInput("R1, LED, SW1"), "pattern");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("3"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
def("zacus_puzzleRadio", function () {
this.appendDummyInput().appendField("🧩 Énigme radio — slot")
.appendField(new Blockly.FieldNumber(4, 1, 8, 1), "id");
this.appendDummyInput().appendField("fréquence cible")
.appendField(new Blockly.FieldNumber(1337, 0, 100000, 1), "frequency").appendField("Hz")
.appendField("± tolérance").appendField(new Blockly.FieldNumber(10, 0, 10000, 1), "tolerance").appendField("Hz");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("4"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
def("zacus_puzzleMorse", function () {
this.appendDummyInput().appendField("🧩 Énigme morse — slot")
.appendField(new Blockly.FieldNumber(5, 1, 8, 1), "id");
this.appendDummyInput().appendField("message")
.appendField(new Blockly.FieldTextInput("ZACUS"), "message")
.appendField("mode").appendField(dropdown(ZACUS_MORSE_MODES), "mode");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("5"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
def("zacus_puzzleNFC", function () {
this.appendDummyInput().appendField("🧩 Énigme symboles NFC — slot")
.appendField(new Blockly.FieldNumber(6, 1, 8, 1), "id");
this.appendDummyInput().appendField("tags attendus (ordre, virgules)")
.appendField(new Blockly.FieldTextInput("7, 2, 11, 4, 9, 1, 8, 3, 12, 6, 10, 5"), "tags");
this.appendDummyInput().appendField("ordre imposé").appendField(new Blockly.FieldCheckbox("TRUE"), "ordered");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("67"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
def("zacus_puzzleCoffre", function () {
this.appendDummyInput().appendField("🧩 Énigme coffre — slot")
.appendField(new Blockly.FieldNumber(7, 1, 8, 1), "id");
this.appendDummyInput().appendField("longueur du code")
.appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres");
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("8"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle);
});
// Bloc matériel : lecture d'un tag NFC hors énigme (catégorie Matériel).
def("zacus_hwNFCRead", function () {
this.appendDummyInput().appendField("NFC — attendu").appendField(new Blockly.FieldTextInput("tag_07"), "expected")
.appendField("→").appendField(new Blockly.FieldTextInput("nfc_uid"), "var")
.appendField("timeout").appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
});
// ── Vague 4 : assemblage du code final / coffre ───────────────────
// Actions (pas des step-extras) mappées aux kinds IR code_assembly /
// coffre_verify / code_fragment. phases.climax.code_assembly.
// Assemble le code final : longueur totale + mapping puzzle→chiffres
// (ex « P1_SON:1-2, P2_CIRCUIT:3, P4_RADIO:4 … »).
def("zacus_codeAssembly", function () {
this.appendDummyInput().appendField("🔐 Assembler le code — longueur")
.appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres");
this.appendDummyInput().appendField("fragments (puzzle:positions, virgules)")
.appendField(new Blockly.FieldTextInput("P1_SON:1-2, P2_CIRCUIT:3, P4_RADIO:4, P5_MORSE:5, P6_SYMBOLES:6-7, P3_QR:8"), "fragments");
stack(this, ZACUS_COLOR.puzzle);
});
// Associe explicitement un fragment de puzzle à une (ou des) position(s).
def("zacus_codeFragment", function () {
this.appendDummyInput().appendField("↳ Fragment puzzle")
.appendField(new Blockly.FieldTextInput("P1_SON"), "puzzle")
.appendField("→ positions").appendField(new Blockly.FieldTextInput("1-2"), "positions");
stack(this, ZACUS_COLOR.puzzle); this.setInputsInline(true);
});
// Vérifie le code assemblé : déclenche l'ouverture / la fin.
def("zacus_coffreVerify", function () {
this.appendDummyInput().appendField("✅ Vérifier coffre — slot")
.appendField(new Blockly.FieldNumber(7, 1, 8, 1), "id");
this.appendDummyInput().appendField("au succès aller à")
.appendField(new Blockly.FieldTextInput("outro"), "on_success");
stack(this, ZACUS_COLOR.puzzle);
});
// ── Phase D2 : binding carte ──────────────────────────────────────
// Bloc contexte c-block « ▸ sur la carte X { … } » : il ne produit aucune
// action propre, il TAGUE chaque action de son slot `body` avec le `board`
// choisi (cf. blocks_to_runtime3.py). previous+next connectés. Réel : le
// routage gateway s'appuie dessus.
def("zacus_hwOnBoard", function () {
this.appendDummyInput().appendField("▸ sur la carte").appendField(dropdown(ZACUS_BOARDS), "board");
this.appendStatementInput("body").setCheck(null).appendField("faire");
stack(this, ZACUS_COLOR.hardware);
});
// ── Phase D4 : transitions réelles ────────────────────────────────
// Blocs « Quand … → aller à [target] » : chacun émet UNE transition avec le
// vrai event_type firmware-backed (button/serial/audio_done/unlock/espnow).
// event_type ∈ runtime3_common.EVENT_TYPES (pas d'extension de schéma, juste
// de l'émission). target = id d'étape cible (même pattern que sceneGoto).
// Voie RÉELLE : consommés côté gateway/web (le firmware n'a pas de moteur de
// transitions, il est piloté pas-à-pas). Compile dans blocks_to_runtime3.py.
def("zacus_onButton", function () {
this.appendDummyInput().appendField("Quand bouton")
.appendField(new Blockly.FieldTextInput("NEXT"), "button")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_onAudioDone", function () {
this.appendDummyInput().appendField("Quand audio terminé → aller à")
.appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_onUnlock", function () {
this.appendDummyInput().appendField("Quand déverrouillage")
.appendField(new Blockly.FieldTextInput("door"), "source")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_onSerial", function () {
this.appendDummyInput().appendField("Quand série reçoit")
.appendField(new Blockly.FieldTextInput("OK"), "token")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_onEspnow", function () {
this.appendDummyInput().appendField("Quand ESP-NOW reçoit")
.appendField(new Blockly.FieldTextInput("ready"), "command")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_onPuzzleSolved", function () {
this.appendDummyInput().appendField("Quand énigme résolue")
.appendField(new Blockly.FieldTextInput("1"), "puzzle")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
def("zacus_onCodeAssembled", function () {
this.appendDummyInput().appendField("Quand code assemblé")
.appendField(new Blockly.FieldTextInput("4242"), "code")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
// Logique
def("zacus_logicIf", function () {
// condition : reporter branché (appendValueInput "condition") OU champ
// texte de repli (rétrocompat strings opaques).
this.appendValueInput("condition").setCheck(null).appendField("si");
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("score > 0"), "condition");
this.appendStatementInput("body").setCheck(null).appendField("alors");
this.appendStatementInput("else").setCheck(null).appendField("sinon");
stack(this, ZACUS_COLOR.logic);
});
def("zacus_logicTimer", function () {
this.appendDummyInput().appendField("Attendre")
.appendField(new Blockly.FieldNumber(5, 0, 600, 0.1), "seconds").appendField("s");
stack(this, ZACUS_COLOR.logic); this.setInputsInline(true);
});
def("zacus_logicScore", function () {
this.appendDummyInput().appendField("Score +")
.appendField(new Blockly.FieldNumber(1, -100, 100, 1), "delta");
stack(this, ZACUS_COLOR.logic); this.setInputsInline(true);
});
def("zacus_logicSetVar", function () {
this.appendDummyInput().appendField("Variable").appendField(new Blockly.FieldTextInput("key"), "name")
.appendField(":=").appendField(new Blockly.FieldTextInput("42"), "value");
stack(this, ZACUS_COLOR.logic); this.setInputsInline(true);
});
// ── Vague 1 : Opérateurs (reporters) ──────────────────────────────
// Tous setOutput(true) : pas de previous/next statement, ils se branchent
// dans un appendValueInput (condition de logicIf/loopWhile/sceneBranch ou
// un opérande d'un autre opérateur). Sérialisés en string d'expression.
def("zacus_opCompare", function () {
this.appendValueInput("A").setCheck(null);
this.appendDummyInput().appendField(dropdown(ZACUS_COMPARE_OPS), "op");
this.appendValueInput("B").setCheck(null);
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
});
def("zacus_opBool", function () {
this.appendValueInput("A").setCheck(null);
this.appendDummyInput().appendField(dropdown(ZACUS_BOOL_OPS), "op");
this.appendValueInput("B").setCheck(null);
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
});
def("zacus_opNot", function () {
this.appendValueInput("A").setCheck(null).appendField("non");
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
});
def("zacus_opArith", function () {
this.appendValueInput("A").setCheck(null);
this.appendDummyInput().appendField(dropdown(ZACUS_ARITH_OPS), "op");
this.appendValueInput("B").setCheck(null);
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
});
def("zacus_valVar", function () {
this.appendDummyInput().appendField("variable").appendField(new Blockly.FieldTextInput("pct"), "name");
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
});
def("zacus_valScore", function () {
this.appendDummyInput().appendField("score courant");
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
});
def("zacus_valNumber", function () {
this.appendDummyInput().appendField(new Blockly.FieldNumber(0), "num");
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
});
def("zacus_valText", function () {
this.appendDummyInput().appendField("“").appendField(new Blockly.FieldTextInput("texte"), "text").appendField("”");
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
});
// ── Vague 1 : Contrôle (statements c-block) ───────────────────────
def("zacus_loopRepeat", function () {
this.appendDummyInput().appendField("répéter")
.appendField(new Blockly.FieldNumber(10, 0, 100000, 1), "times").appendField("fois");
this.appendStatementInput("body").setCheck(null).appendField("faire");
stack(this, ZACUS_COLOR.control);
});
def("zacus_loopWhile", function () {
this.appendValueInput("condition").setCheck(null).appendField("tant que");
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("score < 5"), "condition_text");
this.appendStatementInput("body").setCheck(null).appendField("faire");
stack(this, ZACUS_COLOR.control);
});
// ── Vague 2 : Maître du jeu (game-master adaptatif) ───────────────
// Définit une phase de jeu (intro/profiling/adaptive/climax/outro) avec
// une durée et un déclencheur. phases.*.{id,duration_minutes,trigger}.
def("zacus_phase", function () {
this.appendDummyInput().appendField("🎬 Phase").appendField(new Blockly.FieldTextInput("adaptive"), "id");
this.appendDummyInput().appendField("durée")
.appendField(new Blockly.FieldNumber(20, 0, 240, 1), "duration_minutes").appendField("min");
this.appendDummyInput().appendField("déclencheur").appendField(dropdown(ZACUS_PHASE_TRIGGERS), "trigger");
stack(this, ZACUS_COLOR.gm);
});
// Règle condition→action du game-master (npc_mood_rules.*). La condition
// accepte un reporter branché OU un texte (comme logicIf).
def("zacus_gmRule", function () {
this.appendValueInput("condition").setCheck(null).appendField("⚖ Règle si");
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("pct > 130"), "condition");
this.appendDummyInput().appendField("→ action").appendField(dropdown(ZACUS_GM_ACTIONS), "action");
stack(this, ZACUS_COLOR.gm);
});
// Humeur du NPC (npc_mood_rules.*.mood).
def("zacus_npcSetMood", function () {
this.appendDummyInput().appendField("😐 Humeur NPC").appendField(dropdown(ZACUS_NPC_MOODS), "mood");
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
});
// Donner un indice de niveau N avec sa clé (hint_keys.level_N + give level).
def("zacus_giveHint", function () {
this.appendDummyInput().appendField("💡 Indice niveau").appendField(dropdown([["1","1"],["2","2"],["3","3"]]), "level")
.appendField("clé").appendField(new Blockly.FieldTextInput("hints.P5_MORSE.level_1"), "hint_key");
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
});
// Indice proactif à intervalle régulier (hint_escalation.proactive_hint_interval_seconds).
def("zacus_proactiveHint", function () {
this.appendDummyInput().appendField("🔔 Indice proactif toutes les")
.appendField(new Blockly.FieldNumber(90, 1, 3600, 1), "interval_seconds").appendField("s")
.appendField("clé").appendField(new Blockly.FieldTextInput("hints.P5_MORSE.level_1"), "hint_key");
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
});
// Définit le parcours (liste de puzzles) pour un profil/classe
// (group_classification.*.parcours).
def("zacus_setParcours", function () {
this.appendDummyInput().appendField("🧭 Parcours profil").appendField(new Blockly.FieldTextInput("TECH"), "profile");
this.appendDummyInput().appendField("puzzles (dans l'ordre, virgules)")
.appendField(new Blockly.FieldTextInput("P2_CIRCUIT, P4_RADIO, P5_MORSE"), "puzzles");
stack(this, ZACUS_COLOR.gm);
});
// Bonus conditionnel (scoring.bonus_*). condition = reporter branché OU texte.
def("zacus_scoreBonus", function () {
this.appendValueInput("condition").setCheck(null).appendField("🏆 Bonus si");
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("hints_used == 0"), "condition");
this.appendDummyInput().appendField("→ +").appendField(new Blockly.FieldNumber(200, 0, 10000, 1), "points").appendField("pts");
stack(this, ZACUS_COLOR.gm);
});
// Pénalité de score (scoring.time_penalty_per_minute / hint_penalty).
def("zacus_scorePenalty", function () {
this.appendDummyInput().appendField("⛔ Pénalité").appendField(dropdown(ZACUS_PENALTY_KINDS), "penalty_kind")
.appendField("").appendField(new Blockly.FieldNumber(10, 0, 10000, 1), "points").appendField("pts");
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
});
}
export const ZACUS_BLOCK_TYPES = [
"zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
"zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone","zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait","zacus_hwNFCRead",
"zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
"zacus_boxIMUShake","zacus_boxIRSend",
"zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
"zacus_plipRing","zacus_plipPickupWait",
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
"zacus_puzzleLED","zacus_puzzleRadio","zacus_puzzleMorse","zacus_puzzleNFC","zacus_puzzleCoffre",
"zacus_codeAssembly","zacus_codeFragment","zacus_coffreVerify",
"zacus_hwOnBoard",
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
"zacus_opCompare","zacus_opBool","zacus_opNot","zacus_opArith",
"zacus_valVar","zacus_valScore","zacus_valNumber","zacus_valText",
"zacus_loopRepeat","zacus_loopWhile",
"zacus_phase","zacus_gmRule","zacus_npcSetMood","zacus_giveHint","zacus_proactiveHint","zacus_setParcours","zacus_scoreBonus","zacus_scorePenalty",
];
export const ZACUS_FIELDS_BY_KIND = {
zacus_sceneStart: ["id"],
zacus_sceneEnd: [],
zacus_sceneGoto: ["target"],
zacus_sceneBranch: ["condition","ifTrue","ifFalse"],
zacus_npcSay: ["text","voice","emotion","rate"],
zacus_npcWaitResponse: ["timeout_s"],
zacus_npcIntentMatch: ["intent","then"],
// Phase D4 — transitions réelles : « Quand … → aller à [target] ».
zacus_onButton: ["button","target"],
zacus_onAudioDone: ["target"],
zacus_onUnlock: ["source","target"],
zacus_onSerial: ["token","target"],
zacus_onEspnow: ["command","target"],
zacus_onPuzzleSolved: ["puzzle","target"],
zacus_onCodeAssembled: ["code","target"],
zacus_hwSoundPlay: ["asset"],
zacus_hwAudioStop: [],
zacus_hwAudioVolume: ["level"],
// Phase D3 — média réel SD : chemin SD + boucle. board hérité de hwOnBoard.
zacus_mediaPlay: ["path","loop"],
zacus_hwLCDText: ["line","text"],
zacus_hwLCDClear: [],
zacus_hwLCDImage: ["asset"],
zacus_hwLCDTouchWait: ["zone","timeout_s"],
zacus_hwServo: ["channel","angle"],
zacus_hwReadQR: ["expected"],
zacus_hwLEDPattern: ["pattern"],
zacus_hwBuzzerTone: ["freq","ms"],
zacus_hwRelay: ["channel","state"],
zacus_hwSensorRead: ["pin","var"],
zacus_hwButtonWait: ["button","timeout_s"],
zacus_hwNFCRead: ["expected","var","timeout_s"],
zacus_espnowRegisterPeer: ["alias","mac"],
zacus_espnowSend: ["peer","command"],
zacus_espnowBroadcast: ["command"],
zacus_espnowWait: ["command","timeout_s"],
zacus_boxIMUShake: ["threshold","timeout_s"],
zacus_boxIRSend: ["protocol","code"],
zacus_m5Beep: ["freq","ms"],
zacus_m5LCDText: ["text","color","size"],
zacus_m5ButtonAB: ["button","timeout_s"],
zacus_m5RGBLed: ["color"],
zacus_m5IMUShake: ["threshold","timeout_s"],
zacus_plipRing: ["duration_s"],
zacus_plipPickupWait: ["timeout_s"],
zacus_scene: ["title","subtitle","symbol","effect"],
zacus_puzzleQR: ["id","codes","fragment","board"],
zacus_puzzleSound: ["id","melody","tolerance","fragment","board"],
// Phase D2 — binding carte : bloc contexte (slot `body`, champ `board`).
zacus_hwOnBoard: ["board"],
// Vague 3 — énigmes manquantes (step-extra `puzzle`).
zacus_puzzleLED: ["id","pattern","fragment","board"],
zacus_puzzleRadio: ["id","frequency","tolerance","fragment","board"],
zacus_puzzleMorse: ["id","message","mode","fragment","board"],
zacus_puzzleNFC: ["id","tags","ordered","fragment","board"],
zacus_puzzleCoffre: ["id","length","fragment","board"],
// Vague 4 — assemblage du code final (actions).
zacus_codeAssembly: ["length","fragments"],
zacus_codeFragment: ["puzzle","positions"],
zacus_coffreVerify: ["id","on_success"],
zacus_logicIf: ["condition"],
zacus_logicTimer: ["seconds"],
zacus_logicScore: ["delta"],
zacus_logicSetVar: ["name","value"],
// Vague 1 — opérateurs (reporters). Les opérandes viennent des value
// inputs (sérialisés via `inputs`), seuls le dropdown/littéral sont des
// champs.
zacus_opCompare: ["op"],
zacus_opBool: ["op"],
zacus_opNot: [],
zacus_opArith: ["op"],
zacus_valVar: ["name"],
zacus_valScore: [],
zacus_valNumber: ["num"],
zacus_valText: ["text"],
// Vague 1 — contrôle.
zacus_loopRepeat: ["times"],
zacus_loopWhile: ["condition_text"],
// Vague 2 — Maître du jeu. Les conditions des règles/bonus sont des value
// inputs (sérialisées via `inputs`) avec repli texte `condition`.
zacus_phase: ["id","duration_minutes","trigger"],
zacus_gmRule: ["condition","action"],
zacus_npcSetMood: ["mood"],
zacus_giveHint: ["level","hint_key"],
zacus_proactiveHint: ["interval_seconds","hint_key"],
zacus_setParcours: ["profile","puzzles"],
zacus_scoreBonus: ["condition","points"],
zacus_scorePenalty: ["penalty_kind","points"],
};
// ── DSL honnête : voies « réel » vs « hardware futur » (phase D1) ───────
// Un bloc est RÉEL s'il est effectivement exécuté — soit on-device (firmware
// Freenove master : scene/QR/sound, média, relais ESP-NOW), soit côté gateway
// (logique de transitions/score/variables, couche game-master, opérateurs).
// Un bloc HARDWARE FUTUR est validé et compilé en IR comme avant, mais AUCUN
// executor ne le joue (servo, relais, buzzer, LCD tactile, NFC, énigmes
// LED/Radio/Morse/NFC/Coffre, assemblage code/coffre, cartes M5/BOX-3/PLIP).
// La compilation (blocks_to_runtime3.py) émet un *warning* non bloquant pour
// ces blocs. Garde cette liste en miroir de FUTURE_HW_KINDS côté Python (kinds
// sans le préfixe `zacus_`).
export const ZACUS_REAL_KINDS = new Set([
"zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
"zacus_hwOnBoard",
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
"zacus_opCompare","zacus_opBool","zacus_opNot","zacus_opArith",
"zacus_valVar","zacus_valScore","zacus_valNumber","zacus_valText",
"zacus_loopRepeat","zacus_loopWhile",
"zacus_phase","zacus_gmRule","zacus_npcSetMood","zacus_giveHint","zacus_proactiveHint",
"zacus_setParcours","zacus_scoreBonus","zacus_scorePenalty",
]);
export const ZACUS_FUTURE_HW_KINDS = new Set([
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
"zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone",
"zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait","zacus_hwNFCRead",
"zacus_puzzleLED","zacus_puzzleRadio","zacus_puzzleMorse","zacus_puzzleNFC","zacus_puzzleCoffre",
"zacus_codeAssembly","zacus_codeFragment","zacus_coffreVerify",
"zacus_boxIMUShake","zacus_boxIRSend",
"zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
"zacus_plipRing","zacus_plipPickupWait",
]);
// Toolbox réorganisée en deux voies : catégories RÉELLES en haut, catégories
// « 🔬 … (futur) » regroupées en bas. Chaque type apparaît exactement une fois ;
// l'union des deux voies == ZACUS_BLOCK_TYPES.
export const ZACUS_TOOLBOX_CATEGORIES = [
// ── Voie réelle (exécutée on-device ou côté gateway) ──────────────────
{ name: "Cartes", colour: ZACUS_COLOR.hardware, types: ["zacus_hwOnBoard"] },
{ name: "Scènes", colour: ZACUS_COLOR.scene, types: ["zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch"] },
{ name: "Transitions", colour: ZACUS_COLOR.scene, types: ["zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled"] },
{ name: "NPC voix", colour: ZACUS_COLOR.npc, types: ["zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch"] },
{ name: "Audio", colour: ZACUS_COLOR.audio, types: ["zacus_mediaPlay","zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume"] },
{ name: "ESP-NOW", colour: ZACUS_COLOR.espnow, types: ["zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait"] },
{ name: "Énigmes", colour: ZACUS_COLOR.puzzle, types: ["zacus_scene","zacus_puzzleQR","zacus_puzzleSound"] },
{ name: "Logique", colour: ZACUS_COLOR.logic, types: ["zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar"] },
{ name: "Opérateurs", colour: ZACUS_COLOR.operator, types: ["zacus_opCompare","zacus_opBool","zacus_opNot","zacus_opArith","zacus_valVar","zacus_valScore","zacus_valNumber","zacus_valText"] },
{ name: "Contrôle", colour: ZACUS_COLOR.control, types: ["zacus_loopRepeat","zacus_loopWhile"] },
{ name: "Maître du jeu", colour: ZACUS_COLOR.gm, types: ["zacus_phase","zacus_gmRule","zacus_npcSetMood","zacus_giveHint","zacus_proactiveHint","zacus_setParcours","zacus_scoreBonus","zacus_scorePenalty"] },
// ── Voie hardware futur (validée, non exécutée on-device) ─────────────
{ name: "🔬 Énigmes (futur)", colour: ZACUS_COLOR.puzzle, types: ["zacus_puzzleLED","zacus_puzzleRadio","zacus_puzzleMorse","zacus_puzzleNFC","zacus_puzzleCoffre"] },
{ name: "🔬 Coffre (futur)", colour: ZACUS_COLOR.puzzle, types: ["zacus_codeAssembly","zacus_codeFragment","zacus_coffreVerify"] },
{ name: "🔬 LCD (futur)", colour: ZACUS_COLOR.lcd, types: ["zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait"] },
{ name: "🔬 Matériel (futur)", colour: ZACUS_COLOR.hardware, types: ["zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone","zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait","zacus_hwNFCRead"] },
{ name: "🔬 BOX-3 (futur)", colour: ZACUS_COLOR.box3, types: ["zacus_boxIMUShake","zacus_boxIRSend"] },
{ name: "🔬 M5 (futur)", colour: ZACUS_COLOR.m5, types: ["zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake"] },
{ name: "🔬 PLIP (futur)", colour: ZACUS_COLOR.plip, types: ["zacus_plipRing","zacus_plipPickupWait"] },
];
+4
View File
@@ -9,6 +9,10 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./blockly": {
"types": "./blockly/zacus_blocks.d.mts",
"import": "./blockly/zacus_blocks.mjs"
}
},
"scripts": {
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
serializeBlocksDoc,
parseBlocksDocYaml,
type BlocksNode,
} from './blocks-doc.js';
// @ts-expect-error — plain .mjs catalog, typed via zacus_blocks.d.mts at the package export.
import { ZACUS_FIELDS_BY_KIND, ZACUS_BLOCK_TYPES, ZACUS_TOOLBOX_CATEGORIES } from '../blockly/zacus_blocks.mjs';
// Phase D3 — média réel SD. The Atelier exporter (yaml-export.ts) snapshots
// each block's fields generically from ZACUS_FIELDS_BY_KIND. This test pins
// the catalog contract for `mediaPlay` and proves the generic serializer round
// -trips its `path` + `loop` params with no per-block special-casing.
describe('mediaPlay catalog contract (D3.4)', () => {
it('declares mediaPlay with path + loop fields', () => {
expect(ZACUS_FIELDS_BY_KIND.zacus_mediaPlay).toEqual(['path', 'loop']);
});
it('registers mediaPlay as a known block type', () => {
expect(ZACUS_BLOCK_TYPES).toContain('zacus_mediaPlay');
});
it('places mediaPlay in the real Audio toolbox category', () => {
const audio = ZACUS_TOOLBOX_CATEGORIES.find(
(c: { name: string }) => c.name === 'Audio',
);
expect(audio?.types).toContain('zacus_mediaPlay');
});
});
describe('mediaPlay serialization round-trip (D3.4)', () => {
it('serializes path + loop params and parses them back unchanged', () => {
// Mirror what workspaceToBlocksNodes produces for a mediaPlay block: a
// node carrying the catalog fields as params.
const node: BlocksNode = {
id: 'm1',
kind: 'mediaPlay',
position: [0, 0],
params: { path: 'zacus/master/intro.wav', loop: 'TRUE' },
};
const yaml = serializeBlocksDoc([node]);
const parsed = parseBlocksDocYaml(yaml);
expect(parsed.nodes).toHaveLength(1);
expect(parsed.nodes[0].kind).toBe('mediaPlay');
expect(parsed.nodes[0].params).toEqual({
path: 'zacus/master/intro.wav',
loop: 'TRUE',
});
});
});
@@ -0,0 +1,203 @@
/**
* blocks-doc — the `blocks_studio_version: 2` exchange format shared by the
* Atelier webapp, the zacus-hub native Studio and the gateway compiler
* (tools/zacus-gateway/blocks_to_runtime3.py).
*
* A document is a flat list of nodes; chains are rebuilt from `next`
* pointers, c-block branches (logicIf "alors"/"sinon", loop bodies) from
* `slots`, and reporter operands (value inputs) from `inputs`.
* Kinds carry no `zacus_` prefix (that prefix belongs to the Blockly block
* type names only).
*
* Vague 1 reporter kinds (`opCompare`, `opBool`, `opNot`, `opArith`,
* `valVar`, `valScore`, `valNumber`, `valText`) are value blocks: they have
* no `next`/statement connection and are referenced through another node's
* `inputs`. `serializeReporter()` resolves them to an expression string in
* the loose condition grammar (`score > 5`, `(a) and (b)`), so logicIf /
* loopWhile / sceneBranch can take either a branched reporter or an opaque
* typed string. Control kinds: `loopRepeat` (times + body slot), `loopWhile`
* (condition value input or `condition_text` + body slot).
*
* Vague 2 « Maître du jeu » (game-master adaptatif) kinds: `phase`, `gmRule`,
* `npcSetMood`, `giveHint`, `proactiveHint`, `setParcours`, `scoreBonus`,
* `scorePenalty`. `gmRule` / `scoreBonus` take a branched condition reporter
* (value input `condition`) OR the `condition` text param, resolved the same
* way as logicIf via `serializeReporter()`. They are statement blocks (no
* output), mapped to game-master IR actions by the gateway.
*
* Vague 3 « énigmes manquantes » kinds (step-level `puzzle` objects, like
* `puzzleQR`/`puzzleSound`, never actions): `puzzleLED` (type led),
* `puzzleRadio` (type radio), `puzzleMorse` (type morse), `puzzleNFC`
* (type nfc), `puzzleCoffre` (type keypad). Plus `hwNFCRead` (action
* `nfc_read`). Vague 4 « assemblage du code / coffre » kinds (actions):
* `codeAssembly` (IR code_assembly), `codeFragment` (IR code_fragment),
* `coffreVerify` (IR coffre_verify). See blocks_to_runtime3.py for the IR
* mapping and runtime3_common.py for the validation contract.
*/
import { load } from 'js-yaml';
export const BLOCKS_STUDIO_VERSION = 2;
export interface BlocksNode {
id: string;
kind: string;
position: [number, number];
next?: string;
params?: Record<string, string | number>;
slots?: Record<string, string>;
/** Reporter blocks plugged into value inputs, keyed by input name
* (e.g. {A: "id1", B: "id2"} for opCompare, {condition: "id"} for
* logicIf). Resolved to an expression string by the gateway. */
inputs?: Record<string, string>;
}
export interface BlocksDocument {
version: number;
nodes: BlocksNode[];
}
/** Serialize nodes to the v2 YAML — same shape BlocksYAML.swift emits.
* Strings are JSON-quoted (valid YAML scalars, multiline-safe). */
export function serializeBlocksDoc(nodes: readonly BlocksNode[]): string {
const lines: string[] = [`blocks_studio_version: ${BLOCKS_STUDIO_VERSION}`, 'nodes:'];
if (nodes.length === 0) {
lines.push(' []');
return `${lines.join('\n')}\n`;
}
for (const n of nodes) {
lines.push(` - id: ${JSON.stringify(n.id)}`);
lines.push(` kind: ${n.kind}`);
lines.push(` position: [${Math.round(n.position[0])}, ${Math.round(n.position[1])}]`);
if (n.next) lines.push(` next: ${JSON.stringify(n.next)}`);
const params = n.params ?? {};
const pkeys = Object.keys(params);
if (pkeys.length > 0) {
lines.push(' params:');
for (const k of pkeys) {
const v = params[k];
lines.push(` ${k}: ${typeof v === 'number' ? v : JSON.stringify(v ?? '')}`);
}
}
const slots = n.slots ?? {};
const skeys = Object.keys(slots);
if (skeys.length > 0) {
lines.push(' slots:');
for (const k of skeys) lines.push(` ${k}: ${JSON.stringify(slots[k])}`);
}
const inputs = n.inputs ?? {};
const ikeys = Object.keys(inputs);
if (ikeys.length > 0) {
lines.push(' inputs:');
for (const k of ikeys) lines.push(` ${k}: ${JSON.stringify(inputs[k])}`);
}
}
return `${lines.join('\n')}\n`;
}
/**
* Serialize a reporter node (and its nested value-input children) to an
* expression string in the loose grammar the scenario conditions already use
* (`score > 5`, `pct >= 60 and lives > 0`). Mirrors the Python implementation
* in tools/zacus-gateway/blocks_to_runtime3.py (`_reporter_to_expr`).
*
* Returns "" when the node/ref is missing so callers can fall back to a typed
* text field (backward-compat with opaque condition strings).
*/
export function serializeReporter(
nodeId: string | undefined,
byId: Readonly<Record<string, BlocksNode>>,
): string {
if (!nodeId) return '';
const node = byId[nodeId];
if (!node) return '';
const p = node.params ?? {};
const inp = node.inputs ?? {};
const operand = (name: string): string => serializeReporter(inp[name], byId);
switch (node.kind) {
case 'valScore':
return 'score';
case 'valVar':
return String(p['name'] ?? '').trim();
case 'valNumber': {
const n = p['num'];
return n === undefined || n === null ? '0' : String(n);
}
case 'valText':
return JSON.stringify(String(p['text'] ?? ''));
case 'opCompare': {
const a = operand('A'), b = operand('B');
const op = String(p['op'] ?? '==');
return `${a} ${op} ${b}`;
}
case 'opArith': {
const a = operand('A'), b = operand('B');
const op = String(p['op'] ?? '+');
return `(${a} ${op} ${b})`;
}
case 'opBool': {
const a = operand('A'), b = operand('B');
const op = String(p['op'] ?? 'and');
return `(${a}) ${op} (${b})`;
}
case 'opNot':
return `not (${operand('A')})`;
default:
return '';
}
}
/** Parse a v2 YAML document. Throws on anything that is not a
* blocks_studio_version: 2 mapping with a well-formed nodes list. */
export function parseBlocksDocYaml(text: string): BlocksDocument {
const doc = load(text) as Record<string, unknown> | null;
if (!doc || typeof doc !== 'object') {
throw new Error('YAML : document vide ou racine non-mapping');
}
const version = doc['blocks_studio_version'];
if (version !== BLOCKS_STUDIO_VERSION) {
throw new Error(
`blocks_studio_version ${String(version)} non supporté (attendu: ${BLOCKS_STUDIO_VERSION})`,
);
}
const rawNodes = doc['nodes'];
const nodes: BlocksNode[] = [];
if (Array.isArray(rawNodes)) {
for (const raw of rawNodes) {
if (!raw || typeof raw !== 'object') continue;
const r = raw as Record<string, unknown>;
if (typeof r['id'] !== 'string' || typeof r['kind'] !== 'string') continue;
const pos = Array.isArray(r['position']) ? (r['position'] as unknown[]) : [0, 0];
const node: BlocksNode = {
id: r['id'],
kind: r['kind'],
position: [Number(pos[0]) || 0, Number(pos[1]) || 0],
};
if (typeof r['next'] === 'string') node.next = r['next'];
if (r['params'] && typeof r['params'] === 'object') {
const params: Record<string, string | number> = {};
for (const [k, v] of Object.entries(r['params'] as Record<string, unknown>)) {
if (typeof v === 'string' || typeof v === 'number') params[k] = v;
else if (v != null) params[k] = String(v);
}
node.params = params;
}
if (r['slots'] && typeof r['slots'] === 'object') {
const slots: Record<string, string> = {};
for (const [k, v] of Object.entries(r['slots'] as Record<string, unknown>)) {
if (typeof v === 'string') slots[k] = v;
}
node.slots = slots;
}
if (r['inputs'] && typeof r['inputs'] === 'object') {
const inputs: Record<string, string> = {};
for (const [k, v] of Object.entries(r['inputs'] as Record<string, unknown>)) {
if (typeof v === 'string') inputs[k] = v;
}
node.inputs = inputs;
}
nodes.push(node);
}
}
return { version: BLOCKS_STUDIO_VERSION, nodes };
}
+1
View File
@@ -1,3 +1,4 @@
export * from './types.js';
export * from './constants.js';
export * from './yaml-parser.js';
export * from './blocks-doc.js';
+182 -515
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
# Livre dont vous êtes le héros — L'Énigme du Pharaon (aventure)
title: "L'Énigme du Pharaon"
voice: Thomas
start: entree
passages:
entree:
screen: "L'entrée"
text: >
Votre lampe-torche tremble dans la poussière de trois mille ans. Vous venez
de percer le tombeau scellé du pharaon Sékhet-Râ, que nul n'a violé. Devant
vous, un couloir descend, ses murs couverts de hiéroglyphes. Au sol, une
ligne de sel et un avertissement gravé : « Qui prend sans donner ne ressort
pas. » Votre guide a déjà fui.
choices:
- label: "Avancer dans le couloir aux hiéroglyphes"
goto: couloir
- label: "Étudier l'avertissement gravé"
goto: avertissement
avertissement:
screen: "L'avertissement"
text: >
En déchiffrant, vous comprenez : le tombeau est une balance. Pour chaque
trésor pris, il faut offrir quelque chose en retour, ou la salle se referme.
Une petite fiole d'eau et une plume sont posées dans une niche, comme des
offrandes de départ.
choices:
- label: "Prendre la fiole et la plume, puis avancer"
goto: couloir
- label: "Ne rien toucher et avancer prudemment"
goto: couloir
couloir:
screen: "Le couloir"
text: >
Le couloir débouche sur une fourche. À gauche, une salle dont le sol est
un damier de dalles gravées de symboles. À droite, un puits sombre d'où
monte un cliquetis de scarabées. Devant, une lourde porte de pierre frappée
d'une balance.
choices:
- label: "Traverser la salle au sol en damier"
goto: damier
- label: "Descendre le puits aux scarabées"
goto: puits
- label: "Forcer la porte à la balance"
goto: balance
damier:
screen: "Le damier"
text: >
Chaque dalle porte un symbole : eau, soleil, oiseau, serpent. Une fresque
au mur montre l'ordre sacré : « Suis le Nil — l'eau, puis l'oiseau qui le
survole, puis le soleil qui le sèche. » Marcher sur la mauvaise dalle, et
des pointes jaillissent. La sortie est de l'autre côté.
choices:
- label: "Marcher : eau, oiseau, soleil"
goto: tresor
- label: "Marcher au hasard, en courant"
goto: fin_pointes
puits:
screen: "Le puits"
text: >
Vous descendez. Le fond grouille de scarabées dorés — vivants, ou
mécaniques ? Au centre, sur un socle, repose un cœur d'or, le légendaire
Cœur de Sékhet-Râ. Le prendre déclencherait sûrement les insectes. Un
conduit d'aération, étroit, remonte vers la lumière.
choices:
- label: "Saisir le Cœur d'or"
goto: coeur
- label: "Déposer la plume en offrande, puis le prendre"
goto: offrande
- label: "Renoncer et remonter au couloir"
goto: couloir
coeur:
screen: "Le Cœur d'or"
text: >
Vos doigts se referment sur le Cœur d'or. Aussitôt, les scarabées
s'animent en une marée bruissante et grimpante. Le conduit d'aération est
votre seule issue, mais ramper avec le trésor vous ralentira.
choices:
- label: "Fuir par le conduit, le Cœur serré contre vous"
goto: fin_scarabees
- label: "Lâcher le Cœur pour sauver votre vie"
goto: offrande
offrande:
screen: "L'offrande"
text: >
Vous posez la plume sur le socle avant de prendre le Cœur — un échange,
comme l'exige la balance. Les scarabées s'immobilisent, apaisés. Le Cœur
d'or pulse faiblement dans votre main, tiède. Le conduit remonte vers la
salle de la balance.
choices:
- label: "Remonter vers la porte à la balance"
goto: balance
tresor:
screen: "La chambre au trésor"
text: >
Vous franchissez le damier sans encombre. La chambre regorge d'or, de
lapis-lazuli, de masques funéraires. Au centre, sur un coussin de poussière,
l'Œil d'Horus, scintillant. Mais une seconde porte, scellée d'une balance,
semble être la vraie sortie.
choices:
- label: "Empocher l'Œil d'Horus et gagner la porte"
goto: balance
- label: "Ne prendre que l'Œil, laisser le reste"
goto: balance
- label: "Tout emporter, les bras chargés"
goto: fin_avidite
balance:
screen: "La salle de la balance"
text: >
Une vaste salle. Au centre, une balance de pierre à deux plateaux, gardée
par une statue d'Anubis aux yeux de rubis. Une voix d'outre-tombe résonne :
« Pose sur le plateau ce que vaut ta vie. Si ton cœur est plus léger qu'une
plume, tu passes. Sinon, tu restes. » Le plateau vide attend.
choices:
- label: "Poser le trésor pris sur le plateau"
goto: fin_pesee
- label: "Poser la plume (si vous l'avez gardée)"
goto: fin_plume
- label: "Poser votre lampe, votre seul vrai bien ici"
goto: fin_lampe
- label: "Refuser et tenter de fracasser la statue"
goto: fin_anubis
# ── Fins ──
fin_pointes:
screen: "Le damier fatal"
text: >
Votre pied se pose sur le serpent. Un déclic — et le sol entier hérisse ses
pointes. Vous devenez, vous aussi, une relique du tombeau de Sékhet-Râ, que
le prochain pilleur, dans mille ans, étudiera avec respect. Fin.
fin_scarabees:
screen: "La marée dorée"
text: >
Vous rampez, le Cœur contre la poitrine, mais les scarabées sont plus
rapides. Ils vous recouvrent en une armure vivante et bruissante. Le Cœur
d'or roule au fond du puits, attendant patiemment le prochain imprudent.
Vous avez pris sans donner. Fin.
fin_avidite:
screen: "L'avidité"
text: >
Les bras chargés d'or, vous atteignez la balance — mais elle s'effondre
sous le poids de votre cupidité. La chambre se referme dans un grondement
de pierre. Vous mourrez riche, dans le noir, sur un trésor que personne ne
reverra. Fin.
fin_pesee:
screen: "La pesée"
text: >
Vous posez l'or sur le plateau. La balance penche lourdement : votre butin
pèse plus qu'une vie honnête. Anubis incline la tête. « Tu as choisi l'or
plutôt que le souffle. » La salle se scelle. Vous restez, gardien d'un
trésor que vous ne dépenserez jamais. Fin.
fin_plume:
screen: "Plus léger qu'une plume"
text: >
Vous posez la plume sur le plateau. L'autre plateau, celui de votre cœur,
remonte doucement : vous avez donné autant que pris. Anubis baisse ses yeux
de rubis. La porte scellée coulisse sur le soleil du désert. Vous ressortez
vivant, le Cœur d'or au poing — et la conscience légère. Fin.
fin_lampe:
screen: "Le sacrifice de lumière"
text: >
Vous posez votre lampe sur le plateau — votre seule lumière, votre seul
réconfort. Anubis semble surpris par ce geste désintéressé. La porte
s'ouvre. Vous ressortez à tâtons, dans le noir puis dans l'aube, les mains
vides mais l'âme entière. Le vrai trésor, c'était de ressortir. Fin.
fin_anubis:
screen: "La colère d'Anubis"
text: >
Vous frappez la statue. Les yeux de rubis s'embrasent ; le sable se met à
monter, par le sol, par les murs, comme un sablier retourné. En quelques
souffles, la salle entière n'est plus qu'une dune lisse. On ne pille pas
Anubis. Fin.
+196
View File
@@ -0,0 +1,196 @@
# Livre dont vous êtes le héros — L'Île aux Mille Échos
# Navigation D-pad (curseur ">" + clic). passages {screen, text,
# choices:[{label, goto}]}. Passage sans choices = fin.
title: "L'Île aux Mille Échos"
voice: Thomas
start: plage
passages:
plage:
screen: "La plage"
text: >
Vous reprenez connaissance sur une plage de sable noir, les poumons
brûlants d'eau de mer. De votre voilier, il ne reste qu'une planche et un
coffre échoué. Devant vous, une jungle dense ; à droite, des falaises
percées de grottes ; à gauche, une crique. Quand vous toussez, la jungle
vous répond : « …toussez… toussez… » d'une voix qui n'est pas la vôtre.
choices:
- label: "Ouvrir le coffre échoué"
goto: coffre
- label: "Entrer dans la jungle"
goto: jungle
- label: "Explorer les grottes des falaises"
goto: grottes
coffre:
screen: "Le coffre"
text: >
Le coffre contient une gourde, un couteau, un briquet à amadou et une
carte trempée. La carte montre l'île : une jungle, des grottes, un phare
au sommet, et au centre une spirale annotée « NE PARLE PAS DANS LE PUITS ».
Une plume y est glissée, comme un marque-page.
choices:
- label: "Partir vers le phare par la jungle"
goto: jungle
- label: "Aller droit aux grottes"
goto: grottes
jungle:
screen: "La jungle"
text: >
La jungle bruisse de mille échos décalés : vos propres pas vous reviennent
avec un temps de retard, comme si quelqu'un marchait derrière vous. Un
sentier monte vers une lumière — sans doute le phare. Une trouée latérale
descend vers un puits de pierre, au centre de l'île.
choices:
- label: "Monter le sentier vers le phare"
goto: phare
- label: "Descendre vers le puits central"
goto: puits
- label: "Appeler à l'aide de toutes vos forces"
goto: appel
appel:
screen: "L'appel"
text: >
« Ohé ! Y a quelqu'un ?! » Votre cri se démultiplie, revient de partout,
enfle au lieu de s'éteindre — dix voix, cent voix, toutes la vôtre, qui se
mettent à répondre des choses que vous n'avez pas dites. La jungle frémit.
Quelque chose, nourri de ces voix, se rapproche.
choices:
- label: "Vous taire et courir vers le phare"
goto: phare
- label: "Vous taire et vous cacher dans les grottes"
goto: grottes
grottes:
screen: "Les grottes"
text: >
Les grottes sont une cathédrale d'échos. Le moindre bruit s'y répète à
l'infini, de plus en plus net, de plus en plus humain. Des peintures
pariétales montrent des silhouettes qui se bouchent les oreilles. Un
boyau descend vers un grondement d'eau ; une corniche mène vers la lumière
du dehors et le sentier du phare.
choices:
- label: "Suivre le boyau vers l'eau"
goto: source
- label: "Regagner la corniche et viser le phare"
goto: phare
- label: "Graver votre nom dans la roche"
goto: fin_gravure
source:
screen: "La source"
text: >
Le boyau débouche sur une source souterraine d'une clarté irréelle. À sa
surface flotte un radeau de roseaux, déjà construit, et un message gravé
au-dessus : « D'autres ont fui par l'eau. L'île ne garde que les voix. »
Le courant file vers une issue lumineuse, vers la mer.
choices:
- label: "Embarquer sur le radeau et fuir par l'eau"
goto: fin_radeau
- label: "Remonter, déterminé à percer le secret du puits"
goto: puits
phare:
screen: "Le phare"
text: >
Au sommet, un phare éteint depuis longtemps. La lanterne est intacte ; il
manque juste une flamme. De là-haut, vous voyez tout : la spirale du puits
au centre, et au large… un navire, minuscule, qui passe sans vous voir.
Avec le briquet et l'huile de la lampe, vous pourriez rallumer le phare.
choices:
- label: "Rallumer le phare pour appeler le navire"
goto: fin_phare
- label: "Crier vers le navire de toutes vos forces"
goto: fin_cri
- label: "Descendre d'abord élucider le puits"
goto: puits
puits:
screen: "Le puits"
text: >
Au cœur de l'île, un puits en spirale s'enfonce dans le noir. L'air en
remonte chargé de chuchotements — toutes les voix prisonnières de l'île,
celles de tous les naufragés. Une inscription, encore : « NE PARLE PAS.
Écoute. Et si tu dois parler, dis ton nom, une seule fois, pour le
reprendre. »
choices:
- label: "Vous pencher et écouter en silence"
goto: ecoute
- label: "Crier dans le puits pour défier l'île"
goto: fin_avalee
- label: "Prononcer votre nom, une seule fois"
goto: fin_nom
ecoute:
screen: "L'écoute"
text: >
Vous écoutez, sans un mot. Peu à peu, le chœur des voix dessine une
vérité : l'île n'est pas vivante, elle est affamée de sons, et elle rend
qui lui rend le silence. Une des voix, plus claire, vous souffle : « La
cloche du phare. Sonne-la une fois, et pars sans un mot. »
choices:
- label: "Remonter sonner la cloche du phare"
goto: fin_cloche
- label: "Tenter malgré tout la fuite par l'eau"
goto: source
# ── Fins ──────────────────────────────────────────────────────────────────
fin_gravure:
screen: "Un nom de plus"
text: >
Vous gravez votre nom dans la roche, à côté des centaines d'autres. À
peine la dernière lettre tracée, votre voix vous quitte, aspirée par
l'écho. Vous devenez l'un de ces murmures qui, un jour, souffleront un
conseil à un nouveau naufragé. L'île vous a gardé. Fin.
fin_radeau:
screen: "Par l'eau"
text: >
Vous vous laissez glisser sur le radeau. Le courant souterrain vous crache
en pleine mer, sous les étoiles. Derrière vous, l'île s'éloigne, et son
chœur de voix s'éteint enfin dans le ressac. Trois jours plus tard, un
chalutier vous repêche, muet d'épuisement mais vivant. Vous avez fui. Fin.
fin_phare:
screen: "La lumière"
text: >
La flamme prend, la lentille s'embrase : un faisceau balaie la mer. Le
navire vire de bord. Tandis que la chaloupe approche, le phare semble enfin
respirer, et les échos de l'île s'apaisent, comme rassasiés d'avoir, pour
une fois, servi à sauver quelqu'un. On vous hisse à bord. Sauvé. Fin.
fin_cri:
screen: "Le cri de trop"
text: >
Vous hurlez vers le navire. L'île saisit votre cri au vol, le multiplie,
le retourne — et le navire, croyant à un piège de sirènes, met les voiles.
Votre voix, elle, ne revient pas : elle reste là-haut, à appeler des bateaux
qui fuiront toujours. Vous, vous restez aussi. Fin.
fin_avalee:
screen: "Avalé par l'écho"
text: >
Votre défi résonne dans la spirale, descend, descend, et remonte
centuplé, assourdissant, jusqu'à vous arracher du sol. Le puits vous avale
comme une dernière syllabe. Quelque part, très loin, un futur naufragé
entendra votre cri et croira que l'île est vivante. Fin.
fin_nom:
screen: "Reprendre son nom"
text: >
Vous prononcez votre nom, une seule fois, fermement. Les voix se figent,
surprises — puis vous le rendent, intact, et avec lui un sentier de pierre
qui n'existait pas, droit vers la plage et un canot oublié. L'île, qui ne
retient que les noms perdus, vous laisse partir avec le vôtre. Fin.
fin_cloche:
screen: "Une seule cloche"
text: >
Vous remontez et sonnez la cloche du phare : un coup, grave et pur. L'écho,
pour la première fois, ne se démultiplie pas — il s'éteint, net, comme un
souffle qu'on relâche. Dans ce silence neuf, vous descendez à la plage,
poussez le canot, et ramez sans un mot. Derrière vous, l'île se tait
enfin. Fin.
+210
View File
@@ -0,0 +1,210 @@
# Livre dont vous êtes le héros — Le Manoir de Minuit (épouvante gothique)
title: "Le Manoir de Minuit"
voice: Thomas
start: grille
passages:
grille:
screen: "La grille"
text: >
Votre voiture est tombée en panne sous l'orage, juste devant le manoir
Ravenholt. Les fenêtres sont noires, sauf une, tout en haut, où vacille
une bougie. La grille rouillée n'est pas fermée. Un heurtoir en forme de
main vous attend sur la porte. Minuit sonne quelque part, treize fois.
choices:
- label: "Frapper avec le heurtoir"
goto: hall
- label: "Faire le tour vers la fenêtre éclairée"
goto: serre
hall:
screen: "Le grand hall"
text: >
La porte s'ouvre seule. Le hall est tendu de portraits dont les yeux
semblent suivre vos pas. Un escalier monte vers la bougie ; à droite, une
bibliothèque ; sous l'escalier, une porte de cave d'où monte un courant
d'air froid et une odeur de terre.
choices:
- label: "Monter vers la chambre éclairée"
goto: chambre
- label: "Entrer dans la bibliothèque"
goto: bibliotheque
- label: "Descendre à la cave"
goto: cave
serre:
screen: "La serre"
text: >
Derrière le manoir, une serre brisée déborde de roses noires qui fleurissent
en plein hiver. Une jeune fille en robe ancienne y cueille des fleurs, vous
tournant le dos. « Vous êtes en retard, dit-elle sans se retourner. Le
dîner refroidit depuis cent ans. » Une porte vitrée mène à la salle à
manger.
choices:
- label: "Lui demander qui elle est"
goto: fille
- label: "Entrer dans la salle à manger"
goto: diner
fille:
screen: "Eléonore"
text: >
Elle se retourne : un beau visage, mais aux yeux entièrement noirs. « Je
suis Eléonore Ravenholt. Le manoir nous garde, ma famille et moi, depuis
l'incendie. Il lui faut un invité, chaque nuit, pour rester en vie.
Aujourd'hui, c'est vous. » Elle vous tend une rose noire. « Garde-la. Elle
te protégera. Une fois. »
choices:
- label: "Prendre la rose et entrer dîner"
goto: diner
- label: "Refuser et fuir vers le hall"
goto: hall
bibliotheque:
screen: "La bibliothèque"
text: >
Des milliers de livres, et un seul ouvert sur un lutrin : le registre du
manoir. Chaque page porte le nom d'un invité, et une date de mort. La
dernière ligne est vierge — elle attend un nom. Une plume trempe dans un
encrier rouge. Une cheminée éteinte cache peut-être un passage.
choices:
- label: "Fouiller la cheminée"
goto: passage
- label: "Écrire un autre nom que le vôtre"
goto: fin_nom_ecrit
- label: "Refermer le registre et monter"
goto: chambre
cave:
screen: "La cave"
text: >
La cave est un caveau. Des cercueils alignés, dont les plaques portent les
noms du registre. L'un d'eux est ouvert, vide, votre taille exactement. Au
mur, des outils anciens et un crucifix de fer descellé. Un soupirail, tout
en haut, laisse passer la pluie — et peut-être un corps.
choices:
- label: "Arracher le crucifix de fer"
goto: crucifix
- label: "Vous hisser par le soupirail"
goto: fin_soupirail
- label: "Remonter au hall"
goto: hall
chambre:
screen: "La chambre"
text: >
En haut, une chambre d'enfant figée dans le temps, la bougie brûlant sans
fondre. Dans le miroir, votre reflet a un sourire que vous n'avez pas. Une
vieille horloge marque minuit moins une. Sur le lit, un médaillon ouvert
montre toute la famille Ravenholt — et, au fond, une silhouette
supplémentaire : vous.
choices:
- label: "Briser le miroir au reflet menteur"
goto: fin_miroir
- label: "Empocher le médaillon et redescendre"
goto: diner
- label: "Souffler la bougie qui ne fond pas"
goto: fin_bougie
diner:
screen: "Le dîner"
text: >
Une table dressée pour treize, des mets fumants, la famille Ravenholt
attablée, translucide et souriante. La place du bout vous est réservée.
« Mange avec nous, murmurent-ils d'une seule voix, et reste à jamais. Ou
porte un toast, et l'un de nous prendra ta place. » Un verre de vin sombre
vous attend.
choices:
- label: "Manger et rejoindre la famille"
goto: fin_festin
- label: "Porter un toast à Eléonore, la rose au poing"
goto: fin_toast
- label: "Renverser la table et courir au passage"
goto: passage
passage:
screen: "Le passage"
text: >
Derrière la cheminée, un escalier secret descend vers les fondations. Au
bout, le cœur du manoir : un grand cierge noir, gros comme un homme, qui
brûle d'une flamme froide. C'est lui qui tient le manoir entre deux mondes.
Le crucifix de fer, s'il vous l'avez, pourrait l'éteindre.
choices:
- label: "Éteindre le cierge avec le crucifix"
goto: fin_crucifix
- label: "Souffler le cierge à mains nues"
goto: fin_bougie
- label: "Faire demi-tour, ce lieu vous glace"
goto: hall
crucifix:
screen: "Le crucifix"
text: >
Le crucifix de fer est lourd et glacé dans votre main. Les murs de la cave
gémissent, comme si le manoir savait. Un escalier dérobé, derrière les
cercueils, semble mener au cœur des fondations.
choices:
- label: "Descendre vers les fondations"
goto: passage
- label: "Remonter affronter le dîner"
goto: diner
# ── Fins ──
fin_nom_ecrit:
screen: "Le nom écrit"
text: >
Vous trempez la plume et écrivez le premier nom venu — celui d'un ennemi
lointain. L'encre rouge fume. Très loin, quelqu'un que vous n'aimiez pas
rend l'âme à votre place, et la ligne du registre se referme sur lui. Vous
sortez libre, à l'aube. Mais ce nom-là pèsera sur vous chaque nuit. Fin.
fin_soupirail:
screen: "La fuite par le soupirail"
text: >
Vous vous hissez, la pluie vous gifle, vos ongles s'arrachent — puis l'herbe
détrempée du parc. Vous courez sans vous retourner jusqu'à la route. Au
matin, on ne retrouvera aucun manoir à cet endroit, seulement des ruines
calcinées depuis un siècle. Vous, vous êtes bien vivant. Fin.
fin_miroir:
screen: "Le miroir brisé"
text: >
Vous fracassez le miroir. Le reflet hurle — un cri qui n'est pas le vôtre —
et avec lui, tout le manoir se fissure comme du verre. Les Ravenholt
s'effacent, libérés. Vous vous réveillez dans votre voiture, à l'aube, le
moteur qui tourne enfin. Était-ce un rêve ? Vos mains saignent de mille
éclats. Fin.
fin_bougie:
screen: "La flamme froide"
text: >
Vous soufflez la flamme froide. L'obscurité tombe, totale, vivante. Quand
la lumière revient, vous êtes attablé au dîner, translucide et souriant, la
place du bout enfin occupée. Le manoir attend déjà le prochain invité en
retard. Fin.
fin_festin:
screen: "Le festin éternel"
text: >
Le premier morceau a un goût de cendre et de siècles. À la dernière
bouchée, votre reflet dans la coupe est devenu translucide. Vous prenez
place parmi les Ravenholt, souriant pour toujours, attendant le prochain
voyageur en panne sous l'orage. Fin.
fin_toast:
screen: "Le toast"
text: >
Vous levez votre verre vers Eléonore et brandissez la rose noire. Elle
sourit, presque triste. « Tu as compris. » L'un des convives translucides
se lève à votre place et s'assoit, libéré. La rose se fane d'un coup. Les
portes s'ouvrent sur l'aube. Vous partez, vivant — un seul usage, elle vous
l'avait dit. Fin.
fin_crucifix:
screen: "Le cierge éteint"
text: >
Le fer touche la flamme froide : elle s'éteint dans un long soupir de tout
le bâtiment. Le manoir, privé de son cœur, retourne à la poussière qu'il
aurait dû être depuis cent ans. Les Ravenholt vous remercient d'un dernier
regard apaisé avant de s'effacer. Vous sortez sous un ciel qui s'éclaircit.
Fin.

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