feat(ir): compile solution blocks to puzzles

This commit is contained in:
Claude Worker claude2
2026-06-11 03:14:20 +02:00
parent dd6df1fa69
commit b1486b27ff
3 changed files with 207 additions and 14 deletions
+26 -14
View File
@@ -150,18 +150,30 @@ et pilote l'écran. Un IR de référence exerçant ces champs est fourni :
par `validate_runtime3_document`, testé en bout-à-bout via `POST /game/scenario`
puis `POST /game/step`).
**En revanche, l'authoring ne les génère pas encore.** Les scénarios
canoniques (`game/scenarios/zacus_v2.yaml`, `zacus_v3.yaml`) décrivent les
mêmes énigmes via un vocabulaire distinct — blocs `solution:`
(`frequency_hz`, `qr_payload`, `sequence`) et `scene: SCENE_X` (référence
d'ID d'écran, pas l'objet d'affichage) — qui **n'est pas traduit** vers les
objets `puzzle`/`scene` de l'IR. L'IR embarqué `DEFAULT.json` ne contient
donc aucun champ `puzzle`/`scene`.
**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`.
Chaînon à construire (tâche dédiée) : enseigner au compilateur la
correspondance `solution:``puzzle:` (ex. `frequency_hz`+`tolerance_hz`
`type:sound`/`melody`/`tolerance` ; `qr_payload`/`sequence`
`type:qr`/`codes` ; allocation des `fragment` de code par énigme), et
exposer un objet `scene` d'affichage dans l'authoring. En attendant,
pousser directement un IR comme `EXAMPLE_LOCAL_PUZZLES.json` reste le moyen
d'exercer les énigmes locales depuis la webUI/gateway.
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` (14 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).
Reste à faire (suite) : exposer un éditeur d'objet `scene` d'affichage
(title/subtitle/symbol/effect) dans l'atelier Blockly — au niveau IR/step,
l'objet `scene` est déjà accepté via le passthrough. `EXAMPLE_LOCAL_PUZZLES.json`
reste l'IR de référence le plus complet (puzzle + scene par step).
+63
View File
@@ -19,6 +19,7 @@ sys.path.insert(0, str(REPO_ROOT / "tools" / "scenario"))
from runtime3_common import ( # noqa: E402
compile_runtime3_document,
validate_runtime3_document,
translate_solution_to_puzzle,
)
@@ -124,5 +125,67 @@ class SceneValidation(unittest.TestCase):
validate_runtime3_document(doc)
class SolutionToPuzzleTranslation(unittest.TestCase):
def test_qr_payload_becomes_codes(self):
p = translate_solution_to_puzzle(
{"id": "p_qr", "type": "qr", "scene": "SCENE_QR",
"solution": {"qr_payload": "WIN"}})
self.assertEqual(p["type"], "qr")
self.assertEqual(p["codes"], ["WIN"])
self.assertEqual(p["id"], 3) # default qr slot
def test_qr_sequence_ordered(self):
p = translate_solution_to_puzzle(
{"type": "qr", "solution": {"qr_sequence": ["a", "b", "c"]}})
self.assertEqual(p["codes"], ["a", "b", "c"])
def test_audio_frequency_440_maps_to_midi_69(self):
p = translate_solution_to_puzzle(
{"type": "audio_frequency",
"solution": {"frequency_hz": 440, "tolerance_hz": 10}})
self.assertEqual(p["type"], "sound")
self.assertEqual(p["melody"], [69]) # A4
self.assertEqual(p["id"], 1) # default sound slot
self.assertGreaterEqual(p["tolerance"], 1)
def test_explicit_notes_and_fragment_and_id(self):
p = translate_solution_to_puzzle(
{"type": "sound", "puzzle_id": 4,
"solution": {"notes": [60, 62, 64], "code_fragment": [7, 8]}})
self.assertEqual(p["melody"], [60, 62, 64])
self.assertEqual(p["id"], 4)
self.assertEqual(p["fragment"], [7, 8])
def test_letter_sequence_without_notes_raises(self):
with self.assertRaisesRegex(ValueError, "needs solution.notes"):
translate_solution_to_puzzle(
{"type": "sequence", "id": "lefou",
"solution": {"sequence": ["L", "E", "F", "O", "U"]}})
def test_unsupported_type_returns_none(self):
self.assertIsNone(translate_solution_to_puzzle(
{"type": "rfid", "solution": {"tag": "x"}}))
def test_attaches_to_step_by_scene_and_validates(self):
# Full path: authoring puzzles[] + firmware.steps -> IR with puzzle on
# the matching step, and the result must validate.
doc = compile_runtime3_document({
"id": "T", "version": 3,
"firmware": {"initial_step": "STEP_LA", "steps": [
{"step_id": "STEP_LA", "screen_scene_id": "SCENE_LA",
"transitions": []}]},
"puzzles": [
{"id": "p_la", "type": "audio_frequency", "scene": "SCENE_LA",
"solution": {"frequency_hz": 440, "tolerance_hz": 10},
"code_fragment": [4]}],
})
step = doc["steps"][0]
self.assertIn("puzzle", step)
self.assertEqual(step["puzzle"]["type"], "sound")
self.assertEqual(step["puzzle"]["melody"], [69])
self.assertEqual(step["puzzle"]["fragment"], [4])
validate_runtime3_document(doc)
if __name__ == "__main__":
unittest.main()
+118
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
import json
import logging
import math
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -159,6 +161,116 @@ def _parse_major_version(raw: Any) -> int:
return 1
def _hz_to_midi(hz: float) -> int:
"""Frequency (Hz) -> nearest MIDI note. A4 = 440 Hz = 69."""
return int(round(69 + 12 * math.log2(hz / 440.0)))
# Default firmware puzzle slot (1..8) per authoring puzzle type, matching the
# P1 sound / P3 QR convention. Overridable via `puzzle_id` in the solution.
_DEFAULT_PUZZLE_ID = {"sound": 1, "sequence": 2, "qr": 3}
def translate_solution_to_puzzle(authoring: dict[str, Any]) -> dict[str, Any] | None:
"""Map one authoring puzzle (a `puzzles:` entry) to a firmware IR `puzzle`.
Decisions (documented in specs/ZACUS_RUNTIME_3_SPEC.md):
- type `audio_frequency` -> sound: a sustained tone is a 1-note melody;
frequency_hz -> nearest MIDI note; tolerance_hz -> semitones (>=1).
- type `sequence`/`sound` -> sound: requires explicit MIDI notes in
`solution.notes` (a letter sequence like [L,E,F,O,U] is not a pitch
sequence the firmware can detect — the author must give notes).
- type `qr` -> qr: `qr_payload` (str) or `qr_sequence`
(ordered list) -> `codes`.
- `code_fragment` (1..4 digits) carries the puzzle's contribution to the
assembled code; defaults to the single digit of the firmware id.
- firmware slot id from `puzzle_id`, else the per-type default.
Returns None for non-local puzzle types (the firmware only arms qr/sound).
Raises ValueError on a malformed local puzzle so authoring errors surface
at compile time, not on-device.
"""
if not isinstance(authoring, dict):
return None
ptype = str(authoring.get("type", "")).strip()
sol = authoring.get("solution") if isinstance(authoring.get("solution"), dict) else {}
name = authoring.get("id", authoring.get("name", "?"))
fw_type = None
out: dict[str, Any] = {}
if ptype == "qr":
fw_type = "qr"
if isinstance(sol.get("qr_sequence"), list) and sol["qr_sequence"]:
codes = [str(c) for c in sol["qr_sequence"]]
elif sol.get("qr_payload") is not None:
codes = [str(sol["qr_payload"])]
else:
raise ValueError(f"puzzle {name}: qr needs solution.qr_payload or qr_sequence")
out["codes"] = codes
elif ptype in ("audio_frequency", "sound", "sequence"):
fw_type = "sound"
if isinstance(sol.get("notes"), list) and sol["notes"]:
out["melody"] = [int(n) for n in sol["notes"]]
elif sol.get("frequency_hz") is not None:
out["melody"] = [_hz_to_midi(float(sol["frequency_hz"]))]
else:
raise ValueError(
f"puzzle {name}: sound needs solution.notes (MIDI) or frequency_hz "
f"(a letter `sequence` is not a detectable pitch sequence)")
if sol.get("tolerance_hz") is not None and "notes" not in sol:
# ~ semitone span of the Hz tolerance around the tone (>=1).
hz = float(sol["frequency_hz"])
tol_hz = float(sol["tolerance_hz"])
semis = abs(_hz_to_midi(hz + tol_hz) - _hz_to_midi(hz))
out["tolerance"] = max(1, semis)
else:
out["tolerance"] = int(sol.get("tolerance", 1))
else:
return None # not a local puzzle (remote / unsupported) — skip
pid = authoring.get("puzzle_id", _DEFAULT_PUZZLE_ID[fw_type])
out["id"] = int(pid)
out["type"] = fw_type
frag = authoring.get("code_fragment", sol.get("code_fragment"))
if isinstance(frag, list) and frag:
out["fragment"] = [int(d) for d in frag]
else:
out["fragment"] = [out["id"] % 10] # default: single digit of the id
return out
def _attach_puzzles_from_authoring(data: dict[str, Any], steps: list[dict[str, Any]]) -> None:
"""Attach translated puzzles to steps by scene match. Idempotent: a step
that already carries an explicit `puzzle` keeps it."""
puzzles = data.get("puzzles")
if not isinstance(puzzles, list):
return
by_scene: dict[str, dict[str, Any]] = {}
for st in steps:
sid = st.get("scene_id")
if isinstance(sid, str) and sid:
by_scene.setdefault(sid, st)
for pz in puzzles:
if not isinstance(pz, dict):
continue
# A puzzle the author hasn't expressed in a firmware-translatable form
# (e.g. a letter `sequence` with no MIDI notes) must not abort the whole
# scenario compile — skip it with a warning so the rest still builds.
try:
ir = translate_solution_to_puzzle(pz)
except ValueError as exc:
sys.stderr.write(f"[runtime3] skipping puzzle: {exc}\n")
continue
if ir is None:
continue
scene_ref = normalize_token(str(pz.get("scene", "")), "")
target = by_scene.get(scene_ref)
if target is None or "puzzle" in target:
continue
target["puzzle"] = ir
def compile_runtime3_document(data: dict[str, Any], source_kind: str = "yaml") -> dict[str, Any]:
scenario_id = normalize_token(str(data.get("id", "")), "ZACUS_RUNTIME3")
version = _parse_major_version(data.get("version", 1))
@@ -215,6 +327,12 @@ def compile_runtime3_document(data: dict[str, Any], source_kind: str = "yaml") -
entry_step_id = normalized_order[0]
migration_mode = "linear_import"
# Translate the authoring `puzzles:` vocabulary (solution: blocks) into the
# firmware IR `puzzle` objects and attach them to the matching step (by
# screen_scene_id == puzzle.scene). Steps that already carry an explicit
# `puzzle`/`scene` (via _passthrough_step_extras) are left untouched.
_attach_puzzles_from_authoring(data, steps)
return {
"schema_version": "zacus.runtime3.v1",
"scenario": {