feat(ir): compile solution blocks to puzzles
This commit is contained in:
@@ -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` (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).
|
||||
|
||||
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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user