feat(ir): step puzzle + scene fields
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -109,22 +109,36 @@ def _compile_runtime3_steps_from_firmware(
|
|||||||
elif index < len(normalized_ids) - 1:
|
elif index < len(normalized_ids) - 1:
|
||||||
transitions.append(_linear_transition(normalized_ids[index + 1]))
|
transitions.append(_linear_transition(normalized_ids[index + 1]))
|
||||||
|
|
||||||
compiled_steps.append(
|
compiled = {
|
||||||
{
|
"id": step_id,
|
||||||
"id": step_id,
|
"scene_id": normalize_token(str(step.get("screen_scene_id", "")), f"SCENE_{index + 1}"),
|
||||||
"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", "")), ""),
|
||||||
"audio_pack_id": normalize_token(str(step.get("audio_pack_id", "")), ""),
|
"actions": [str(action) for action in step.get("actions", []) if str(action).strip()],
|
||||||
"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()],
|
||||||
"apps": [str(app) for app in step.get("apps", []) if str(app).strip()],
|
"transitions": transitions,
|
||||||
"transitions": transitions,
|
"narrative": str(narrative.get("narrative", "")),
|
||||||
"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"])
|
entry_step_id = normalize_token(str(firmware.get("initial_step", "")), compiled_steps[0]["id"])
|
||||||
return compiled_steps, entry_step_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:
|
def _parse_major_version(raw: Any) -> int:
|
||||||
"""Tolerate both int versions (1, 2, 3) and semantic strings ("3.1").
|
"""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]] = []
|
transitions: list[dict[str, Any]] = []
|
||||||
if index < len(normalized_order) - 1:
|
if index < len(normalized_order) - 1:
|
||||||
transitions.append(_linear_transition(normalized_order[index + 1]))
|
transitions.append(_linear_transition(normalized_order[index + 1]))
|
||||||
steps.append(
|
compiled = {
|
||||||
{
|
"id": step_id,
|
||||||
"id": step_id,
|
"scene_id": scene_id,
|
||||||
"scene_id": scene_id,
|
"audio_pack_id": "",
|
||||||
"audio_pack_id": "",
|
"actions": [],
|
||||||
"actions": [],
|
"apps": [],
|
||||||
"apps": [],
|
"transitions": transitions,
|
||||||
"transitions": transitions,
|
"narrative": str(narrative.get("narrative", "")),
|
||||||
"narrative": str(narrative.get("narrative", "")),
|
}
|
||||||
}
|
_passthrough_step_extras(narrative, compiled)
|
||||||
)
|
steps.append(compiled)
|
||||||
entry_step_id = normalized_order[0]
|
entry_step_id = normalized_order[0]
|
||||||
migration_mode = "linear_import"
|
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}]")
|
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:
|
def validate_runtime3_document(document: dict[str, Any]) -> None:
|
||||||
if document.get("schema_version") != "zacus.runtime3.v1":
|
if document.get("schema_version") != "zacus.runtime3.v1":
|
||||||
raise ValueError("schema_version must be 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")
|
raise ValueError(f"step {step_id} actions must be a list")
|
||||||
for idx, action in enumerate(actions):
|
for idx, action in enumerate(actions):
|
||||||
validate_action(action, step_id=step_id, path=f"actions[{idx}]")
|
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", [])
|
transitions = step.get("transitions", [])
|
||||||
if not isinstance(transitions, list):
|
if not isinstance(transitions, list):
|
||||||
raise ValueError(f"step {step_id} transitions must be a list")
|
raise ValueError(f"step {step_id} transitions must be a list")
|
||||||
|
|||||||
Reference in New Issue
Block a user