# Zacus Runtime 3 Specification ## Goal Define a portable runtime artifact that can be compiled from YAML or Blockly-authored content and executed by firmware or simulated locally. ## Design Principles - Narrative canon remains in YAML during migration. - Runtime IR is deterministic, versioned, and JSON serializable. - Firmware consumes Runtime 3 but does not own narrative semantics. - Import from legacy canonical YAML is allowed in a `linear_import` migration mode. ## IR Shape ```json { "schema_version": "zacus.runtime3.v1", "scenario": { "id": "ZACUS_V2", "version": 3, "title": "Le mystère du Professeur Zacus", "entry_step_id": "STEP_U_SON_PROTO", "source_kind": "yaml" }, "steps": [ { "id": "STEP_U_SON_PROTO", "scene_id": "SCENE_U_SON_PROTO", "audio_pack_id": "", "actions": [], "apps": [], "transitions": [] } ], "metadata": { "migration_mode": "native", "generated_by": "zacus_runtime3" } } ``` ## Transition Model - `event_type`: `button`, `serial`, `timer`, `audio_done`, `unlock`, `espnow`, `action` - `event_name`: opaque token preserved as runtime contract - `target_step_id`: required - `priority`: integer, lower first - `after_ms`: only meaningful for timer-style transitions ## Migration Modes - `native`: authored directly for Runtime 3 with explicit transitions - `linear_import`: derived from the legacy canonical YAML using ordered steps when no explicit graph exists ## Execution Model - Runtime loads one IR document. - Entry step is resolved from `scenario.entry_step_id`. - A simulator can replay deterministic transition events without hardware. - Firmware adapters may enrich steps with board-specific actions, but not mutate story flow semantics. ## Action Shape Actions are emitted by authoring tools (blocks studio, firmware import, hand-written YAML) and consumed by runtime adapters. Two equivalent shapes are accepted: - **String form** (legacy firmware import): `"led_pattern:rainbow"` — opaque, runtime parses at its discretion. - **Dict form** (preferred for new authoring): `{ "kind": "", ...payload }`. ### Standard action kinds | `kind` | Payload | Semantics | |--------|---------|-----------| | `tts_say` | `text: string` | Speak `text` via the active TTS backend | | `wait_user_voice` | `timeout_ms: int` | Block until utterance end or timeout | | `hw_servo` | `channel: int, angle: int` | Set servo on `channel` to `angle` degrees | | `qr_expect` | `value: string` | Arm the QR matcher with expected payload | | `led_pattern` | `pattern: string` | Play a named LED pattern | | `sound_play` | `asset: string` | Play an asset from the media manager | | `score_add` | `delta: int` | Mutate the run-scoped score variable | | `set_var` | `name: string, value: string` | Set a scenario variable | | `condition` | `expr: string, then: [action,…], else: [action,…]` | **Standalone branching.** Evaluate `expr`; execute the `then` action list if truthy, `else` otherwise. Nested `condition` actions are allowed. | ### `condition` action The `condition` action keeps **branching local to the current step** — no extra transitions or step duplication needed for simple if/else logic. The runtime evaluates `expr` against the current variable scope (left intentionally loose; runtimes that don't implement evaluation MAY treat unknown `expr` as falsy and log it). Authoring tools that produce `condition` actions (e.g. `BlockKind.logicIf` in the Blocks Studio) MUST place all branch-local actions inside `then` / `else`. Use a transition (event_type `action`, event_name `goto:`) when you need to jump to another step, not a `condition`. Example: ```json { "kind": "condition", "expr": "score >= 3", "then": [ { "kind": "tts_say", "text": "Bravo, niveau suivant." }, { "kind": "set_var", "name": "unlocked_act2", "value": "true" } ], "else": [ { "kind": "tts_say", "text": "Encore un effort." } ] } ``` ## Step extras: `puzzle` and `scene` (master local puzzles + display) Steps MAY carry two optional objects, passed through verbatim by the compiler and consumed by the ESP32 master firmware (`ESP32_ZACUS/idf_zacus`, contract mirrored in `components/game_endpoint/include/puzzle_binding.h`). Validation is STRICT at the gateway/compiler; the firmware re-validates leniently. ### `puzzle` — local puzzle arming (P1 sound / P3 QR) Armed when the master enters the step (`POST /game/step`). On solve, the `fragment` digits are reported into the master's assembled code. ```json { "id": 3, "type": "qr", "codes": ["zacus-qr-1", "zacus-qr-2"], "fragment": [5] } { "id": 1, "type": "sound", "melody": [60, 62, 64, 65], "tolerance": 1, "fragment": [1, 2] } ``` - `id`: int 1..8 (puzzle slot in the master's `puzzle_state`). - `type`: `qr` (camera + ordered scan) or `sound` (mic + melody). - `codes` (qr): 1..16 strings, each < 32 chars, scanned in order. - `melody` (sound): 1..32 MIDI notes 0..127; `tolerance`: int >= 0 semitones (default 1). - `fragment`: 1..4 decimal digits (ints 0..9) contributed to the code. ### `scene` — master display metadata Drives the master's on-device scene view (title/subtitle/symbol + effect). All fields optional. ```json { "title": "MISSION QR", "subtitle": "scannez dans l'ordre", "symbol": "RUN", "effect": "gyro" } ``` - `title` <= 47 chars, `subtitle` <= 63, `symbol` <= 15. - `effect`: `pulse` (default) | `glitch` | `gyro` | `none`. ### État de l'authoring et chaînon manquant Au stade actuel, les champs `puzzle`/`scene` sont **portés de bout en bout côté exécution** : le compilateur (`runtime3_common.py`) les transmet verbatim de l'IR au firmware, qui arme les énigmes locales (P1 son / P3 QR) et pilote l'écran. Un IR de référence exerçant ces champs est fourni : `hardware/firmware/data/story/runtime3/EXAMPLE_LOCAL_PUZZLES.json` (validé par `validate_runtime3_document`, testé en bout-à-bout via `POST /game/scenario` puis `POST /game/step`). **Le pont authoring → firmware est désormais construit.** Le compilateur traduit le vocabulaire d'authoring des `puzzles:` (blocs `solution:`) vers les objets `puzzle` de l'IR et les **attache au step correspondant** (par `screen_scene_id` == `puzzle.scene`). Fonctions : `translate_solution_to_puzzle` et `_attach_puzzles_from_authoring` dans `runtime3_common.py`. Table de correspondance `solution:` → `puzzle:` : | Authoring (`type` + `solution`) | IR `puzzle` | |---|---| | `qr` + `qr_payload: "X"` | `type:qr`, `codes:["X"]` | | `qr` + `qr_sequence: [a,b]` | `type:qr`, `codes:[a,b]` (ordre conservé) | | `audio_frequency` + `frequency_hz` (+ `tolerance_hz`) | `type:sound`, `melody:[hz→MIDI]`, `tolerance` (≈ semitons, ≥1) | | `sound`/`sequence` + `solution.notes: [MIDI…]` | `type:sound`, `melody:[…]` | Règles : le slot firmware `id` vient de `puzzle_id` sinon d'un défaut par type (sound→1, sequence→2, qr→3) ; le `fragment` (1–4 chiffres) vient de `code_fragment` sinon vaut le chiffre de l'`id`. Une énigme non traduisible (ex. `sequence` de lettres sans `notes` MIDI — une suite de lettres n'est pas une séquence de hauteurs détectable) est **ignorée avec un avertissement**, sans casser la compilation du scénario. Un step portant déjà un `puzzle` explicite n'est pas écrasé (le passthrough direct prime). Côté atelier Blockly, la catégorie « Énigmes » expose trois blocs step-level (catalogue `frontend-v3/packages/shared/blockly/zacus_blocks.mjs`, export `tools/zacus-gateway/blocks_to_runtime3.py`) : `zacus_scene` (title/subtitle/symbol/effect → objet `scene`), `zacus_puzzleQR` (slot, codes ordonnés séparés par virgules, fragment) et `zacus_puzzleSound` (slot, mélodie MIDI séparée par virgules, tolérance, fragment) → objet `puzzle`. Ces blocs n'émettent pas d'actions : ils s'attachent au step courant (un de chaque par step, le dernier gagne avec avertissement) ; l'export tronque/borne les valeurs hors limites (avec avertissement) pour que l'IR émis passe toujours la validation stricte. Tests : `tests/runtime3/test_blocks_export_puzzle_scene.py`. `EXAMPLE_LOCAL_PUZZLES.json` reste l'IR de référence le plus complet (puzzle + scene par step).