feat(ir): step puzzle + scene fields

This commit is contained in:
Claude Worker claude2
2026-06-10 22:57:14 +02:00
parent 9e78dd7b6b
commit 7e690f5a20
3 changed files with 267 additions and 22 deletions
+43
View File
@@ -96,3 +96,46 @@ Example:
]
}
```
## 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`.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Runtime 3 IR: optional per-step `puzzle` / `scene` objects.
Firmware contract: ESP32_ZACUS idf_zacus components/game_endpoint/
include/puzzle_binding.h — the master arms local puzzles (P1 sound /
P3 QR) and drives its display scene from these fields. The compiler
passes them through verbatim; validation is strict at this gateway side.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "tools" / "scenario"))
from runtime3_common import ( # noqa: E402
compile_runtime3_document,
validate_runtime3_document,
)
def _doc_with_step(step_extra: dict) -> dict:
step = {"step_id": "STEP_X", "transitions": []}
step.update(step_extra)
return {
"id": "TEST",
"version": 3,
"firmware": {"steps": [step], "initial_step": "STEP_X"},
}
QR_PUZZLE = {
"id": 3,
"type": "qr",
"codes": ["zacus-qr-1", "zacus-qr-2"],
"fragment": [5],
}
SOUND_PUZZLE = {
"id": 1,
"type": "sound",
"melody": [60, 62, 64, 65],
"tolerance": 1,
"fragment": [1, 2],
}
SCENE = {
"title": "MISSION QR",
"subtitle": "scannez dans l'ordre",
"symbol": "RUN",
"effect": "gyro",
}
class PuzzleScenePassthrough(unittest.TestCase):
def test_puzzle_and_scene_ride_through_to_ir(self):
doc = compile_runtime3_document(
_doc_with_step({"puzzle": QR_PUZZLE, "scene": SCENE}))
step = doc["steps"][0]
self.assertEqual(step["puzzle"], QR_PUZZLE)
self.assertEqual(step["scene"], SCENE)
validate_runtime3_document(doc) # must not raise
def test_steps_without_extras_are_untouched(self):
doc = compile_runtime3_document(_doc_with_step({}))
step = doc["steps"][0]
self.assertNotIn("puzzle", step)
self.assertNotIn("scene", step)
validate_runtime3_document(doc)
def test_sound_puzzle_validates(self):
doc = compile_runtime3_document(
_doc_with_step({"puzzle": SOUND_PUZZLE}))
validate_runtime3_document(doc)
class PuzzleValidation(unittest.TestCase):
def _reject(self, puzzle: dict, fragment_msg: str):
doc = compile_runtime3_document(_doc_with_step({"puzzle": puzzle}))
with self.assertRaisesRegex(ValueError, fragment_msg):
validate_runtime3_document(doc)
def test_rejects_id_out_of_range(self):
bad = dict(QR_PUZZLE, id=9)
self._reject(bad, "puzzle.id")
def test_rejects_unknown_type(self):
bad = dict(QR_PUZZLE, type="laser")
self._reject(bad, "puzzle.type")
def test_rejects_non_digit_fragment(self):
bad = dict(QR_PUZZLE, fragment=[12])
self._reject(bad, "puzzle.fragment")
def test_rejects_qr_without_codes(self):
bad = {k: v for k, v in QR_PUZZLE.items() if k != "codes"}
self._reject(bad, "puzzle.codes")
def test_rejects_long_qr_label(self):
bad = dict(QR_PUZZLE, codes=["x" * 32])
self._reject(bad, "puzzle.codes")
def test_rejects_melody_note_out_of_midi_range(self):
bad = dict(SOUND_PUZZLE, melody=[60, 200])
self._reject(bad, "puzzle.melody")
def test_rejects_negative_tolerance(self):
bad = dict(SOUND_PUZZLE, tolerance=-1)
self._reject(bad, "puzzle.tolerance")
class SceneValidation(unittest.TestCase):
def test_rejects_unknown_effect(self):
doc = compile_runtime3_document(
_doc_with_step({"scene": dict(SCENE, effect="discoball")}))
with self.assertRaisesRegex(ValueError, "scene.effect"):
validate_runtime3_document(doc)
def test_rejects_over_long_title(self):
doc = compile_runtime3_document(
_doc_with_step({"scene": {"title": "x" * 48}}))
with self.assertRaisesRegex(ValueError, "scene.title"):
validate_runtime3_document(doc)
if __name__ == "__main__":
unittest.main()
+96 -22
View File
@@ -109,22 +109,36 @@ def _compile_runtime3_steps_from_firmware(
elif index < len(normalized_ids) - 1:
transitions.append(_linear_transition(normalized_ids[index + 1]))
compiled_steps.append(
{
"id": step_id,
"scene_id": normalize_token(str(step.get("screen_scene_id", "")), f"SCENE_{index + 1}"),
"audio_pack_id": normalize_token(str(step.get("audio_pack_id", "")), ""),
"actions": [str(action) for action in step.get("actions", []) if str(action).strip()],
"apps": [str(app) for app in step.get("apps", []) if str(app).strip()],
"transitions": transitions,
"narrative": str(narrative.get("narrative", "")),
}
)
compiled = {
"id": step_id,
"scene_id": normalize_token(str(step.get("screen_scene_id", "")), f"SCENE_{index + 1}"),
"audio_pack_id": normalize_token(str(step.get("audio_pack_id", "")), ""),
"actions": [str(action) for action in step.get("actions", []) if str(action).strip()],
"apps": [str(app) for app in step.get("apps", []) if str(app).strip()],
"transitions": transitions,
"narrative": str(narrative.get("narrative", "")),
}
_passthrough_step_extras(step, compiled)
compiled_steps.append(compiled)
entry_step_id = normalize_token(str(firmware.get("initial_step", "")), compiled_steps[0]["id"])
return compiled_steps, entry_step_id
def _passthrough_step_extras(step_src: dict[str, Any], compiled: dict[str, Any]) -> None:
"""Carry the optional `puzzle` / `scene` objects through to the IR.
Firmware contract: ESP32_ZACUS idf_zacus components/game_endpoint/
include/puzzle_binding.h (puzzle: local P1 sound / P3 QR arming;
scene: display metadata title/subtitle/symbol/effect). Validation is
strict here at the gateway side — see _validate_step_puzzle/_scene.
"""
for key in ("puzzle", "scene"):
value = step_src.get(key)
if isinstance(value, dict) and value:
compiled[key] = value
def _parse_major_version(raw: Any) -> int:
"""Tolerate both int versions (1, 2, 3) and semantic strings ("3.1").
@@ -187,17 +201,17 @@ def compile_runtime3_document(data: dict[str, Any], source_kind: str = "yaml") -
transitions: list[dict[str, Any]] = []
if index < len(normalized_order) - 1:
transitions.append(_linear_transition(normalized_order[index + 1]))
steps.append(
{
"id": step_id,
"scene_id": scene_id,
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": transitions,
"narrative": str(narrative.get("narrative", "")),
}
)
compiled = {
"id": step_id,
"scene_id": scene_id,
"audio_pack_id": "",
"actions": [],
"apps": [],
"transitions": transitions,
"narrative": str(narrative.get("narrative", "")),
}
_passthrough_step_extras(narrative, compiled)
steps.append(compiled)
entry_step_id = normalized_order[0]
migration_mode = "linear_import"
@@ -258,6 +272,62 @@ def validate_action(action: Any, *, step_id: str, path: str = "actions") -> None
validate_action(sub, step_id=step_id, path=f"{path}.{branch}[{sub_idx}]")
def _validate_step_puzzle(puzzle: Any, step_id: str) -> None:
"""Strict gateway-side mirror of the firmware constraints
(puzzle_binding.h: PB_MAX_* / PUZZLE_MAX_ID / SEQ/MELODY limits)."""
if not isinstance(puzzle, dict):
raise ValueError(f"step {step_id} puzzle must be an object")
pid = puzzle.get("id")
if not isinstance(pid, int) or isinstance(pid, bool) or not (1 <= pid <= 8):
raise ValueError(f"step {step_id} puzzle.id must be an int in 1..8")
ptype = puzzle.get("type")
if ptype not in ("qr", "sound"):
raise ValueError(f"step {step_id} puzzle.type must be 'qr' or 'sound'")
fragment = puzzle.get("fragment")
if (not isinstance(fragment, list) or not (1 <= len(fragment) <= 4)
or any(not isinstance(v, int) or isinstance(v, bool)
or not (0 <= v <= 9) for v in fragment)):
raise ValueError(
f"step {step_id} puzzle.fragment must be 1..4 digits (0..9)")
if ptype == "qr":
codes = puzzle.get("codes")
if (not isinstance(codes, list) or not (1 <= len(codes) <= 16)
or any(not isinstance(c, str) or not c or len(c) >= 32
for c in codes)):
raise ValueError(
f"step {step_id} puzzle.codes must be 1..16 strings (<32 chars)")
else: # sound
melody = puzzle.get("melody")
if (not isinstance(melody, list) or not (1 <= len(melody) <= 32)
or any(not isinstance(n, int) or isinstance(n, bool)
or not (0 <= n <= 127) for n in melody)):
raise ValueError(
f"step {step_id} puzzle.melody must be 1..32 MIDI notes (0..127)")
tol = puzzle.get("tolerance", 1)
if not isinstance(tol, int) or isinstance(tol, bool) or tol < 0:
raise ValueError(
f"step {step_id} puzzle.tolerance must be an int >= 0")
def _validate_step_scene(scene: Any, step_id: str) -> None:
"""Scene metadata is lenient firmware-side but strict at the gateway:
catch authoring mistakes before they reach a device."""
if not isinstance(scene, dict):
raise ValueError(f"step {step_id} scene must be an object")
limits = {"title": 47, "subtitle": 63, "symbol": 15}
for key, max_len in limits.items():
value = scene.get(key)
if value is None:
continue
if not isinstance(value, str) or len(value) > max_len:
raise ValueError(
f"step {step_id} scene.{key} must be a string <= {max_len} chars")
effect = scene.get("effect")
if effect is not None and effect not in ("pulse", "glitch", "gyro", "none"):
raise ValueError(
f"step {step_id} scene.effect must be pulse|glitch|gyro|none")
def validate_runtime3_document(document: dict[str, Any]) -> None:
if document.get("schema_version") != "zacus.runtime3.v1":
raise ValueError("schema_version must be zacus.runtime3.v1")
@@ -283,6 +353,10 @@ def validate_runtime3_document(document: dict[str, Any]) -> None:
raise ValueError(f"step {step_id} actions must be a list")
for idx, action in enumerate(actions):
validate_action(action, step_id=step_id, path=f"actions[{idx}]")
if "puzzle" in step:
_validate_step_puzzle(step["puzzle"], step_id)
if "scene" in step:
_validate_step_scene(step["scene"], step_id)
transitions = step.get("transitions", [])
if not isinstance(transitions, list):
raise ValueError(f"step {step_id} transitions must be a list")