feat(blocks): scene/puzzle blocks in atelier
Catégorie « Énigmes » dans le catalogue Blockly canonique : zacus_scene (écran maître title/subtitle/symbol/effect), zacus_puzzleQR et zacus_puzzleSound. L'export blocks_to_runtime3 les attache au step courant comme objets IR scene/puzzle (pas des actions), avec clamping/troncature vers les limites strictes du gateway pour que l'IR émis valide toujours. Copie hub régénérée via sync_blockly_catalog.sh. 14 tests d'export ajoutés (60 verts).
This commit is contained in:
@@ -25,8 +25,17 @@ const ZACUS_COLOR = {
|
||||
m5: "#4D73A6",
|
||||
plip: "#C74D8C",
|
||||
logic: "#F2A633",
|
||||
puzzle: "#59A86C",
|
||||
};
|
||||
|
||||
// Effets d'affichage de la vue scène du maître (puzzle_binding.h).
|
||||
const ZACUS_SCENE_EFFECTS = [
|
||||
["pulse", "pulse"],
|
||||
["glitch", "glitch"],
|
||||
["gyro", "gyro"],
|
||||
["aucun", "none"],
|
||||
];
|
||||
|
||||
// « Zacus dit » voice options. `voice` maps to the voice-bridge `voice_ref`
|
||||
// (reference clip for F5-TTS voice cloning); emotion + rate are stored in the
|
||||
// IR (tts_say) as forward-compat fields — the bridge ignores them until it
|
||||
@@ -246,6 +255,38 @@ function registerZacusBlocks(Blockly) {
|
||||
stack(this, ZACUS_COLOR.plip); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// Énigmes locales du maître — ces blocs ne produisent pas d'actions : ils
|
||||
// attachent les objets IR `scene` / `puzzle` au step courant (un seul de
|
||||
// chaque par step, voir specs/ZACUS_RUNTIME_3_SPEC.md « Step extras »).
|
||||
def("zacus_scene", function () {
|
||||
this.appendDummyInput().appendField("🖥 Écran maître — titre")
|
||||
.appendField(new Blockly.FieldTextInput("MISSION QR"), "title");
|
||||
this.appendDummyInput().appendField("sous-titre")
|
||||
.appendField(new Blockly.FieldTextInput("scannez dans l'ordre"), "subtitle");
|
||||
this.appendDummyInput().appendField("symbole").appendField(new Blockly.FieldTextInput("RUN"), "symbol")
|
||||
.appendField("effet").appendField(dropdown(ZACUS_SCENE_EFFECTS), "effect");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleQR", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme QR — slot")
|
||||
.appendField(new Blockly.FieldNumber(3, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("codes (dans l'ordre, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("zacus-qr-1, zacus-qr-2"), "codes");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("3"), "fragment");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleSound", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme son — slot")
|
||||
.appendField(new Blockly.FieldNumber(1, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("mélodie (notes MIDI, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("60, 62, 64, 65"), "melody")
|
||||
.appendField("tolérance").appendField(new Blockly.FieldNumber(1, 0, 12, 1), "tolerance");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("1"), "fragment");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
|
||||
// Logique
|
||||
def("zacus_logicIf", function () {
|
||||
this.appendDummyInput().appendField("si").appendField(new Blockly.FieldTextInput("score > 0"), "condition");
|
||||
@@ -280,6 +321,7 @@ const ZACUS_BLOCK_TYPES = [
|
||||
"zacus_boxIMUShake","zacus_boxIRSend",
|
||||
"zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
|
||||
"zacus_plipRing","zacus_plipPickupWait",
|
||||
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
|
||||
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
|
||||
];
|
||||
|
||||
@@ -318,6 +360,9 @@ const ZACUS_FIELDS_BY_KIND = {
|
||||
zacus_m5IMUShake: ["threshold","timeout_s"],
|
||||
zacus_plipRing: ["duration_s"],
|
||||
zacus_plipPickupWait: ["timeout_s"],
|
||||
zacus_scene: ["title","subtitle","symbol","effect"],
|
||||
zacus_puzzleQR: ["id","codes","fragment"],
|
||||
zacus_puzzleSound: ["id","melody","tolerance","fragment"],
|
||||
zacus_logicIf: ["condition"],
|
||||
zacus_logicTimer: ["seconds"],
|
||||
zacus_logicScore: ["delta"],
|
||||
@@ -334,6 +379,7 @@ const ZACUS_TOOLBOX_CATEGORIES = [
|
||||
{ name: "BOX-3", colour: ZACUS_COLOR.box3, types: ["zacus_boxIMUShake","zacus_boxIRSend"] },
|
||||
{ name: "M5", colour: ZACUS_COLOR.m5, types: ["zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake"] },
|
||||
{ name: "PLIP", colour: ZACUS_COLOR.plip, types: ["zacus_plipRing","zacus_plipPickupWait"] },
|
||||
{ name: "Énigmes", colour: ZACUS_COLOR.puzzle, types: ["zacus_scene","zacus_puzzleQR","zacus_puzzleSound"] },
|
||||
{ name: "Logique", colour: ZACUS_COLOR.logic, types: ["zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar"] },
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export declare const ZACUS_COLOR: Record<string, string>;
|
||||
export declare const ZACUS_VOICES: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_EMOTIONS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_SCENE_EFFECTS: ReadonlyArray<[string, string]>;
|
||||
|
||||
/** Register every zacus_* block shape against the given Blockly module. */
|
||||
export declare function registerZacusBlocks(blockly: unknown): void;
|
||||
|
||||
@@ -21,8 +21,17 @@ export const ZACUS_COLOR = {
|
||||
m5: "#4D73A6",
|
||||
plip: "#C74D8C",
|
||||
logic: "#F2A633",
|
||||
puzzle: "#59A86C",
|
||||
};
|
||||
|
||||
// Effets d'affichage de la vue scène du maître (puzzle_binding.h).
|
||||
export const ZACUS_SCENE_EFFECTS = [
|
||||
["pulse", "pulse"],
|
||||
["glitch", "glitch"],
|
||||
["gyro", "gyro"],
|
||||
["aucun", "none"],
|
||||
];
|
||||
|
||||
// « Zacus dit » voice options. `voice` maps to the voice-bridge `voice_ref`
|
||||
// (reference clip for F5-TTS voice cloning); emotion + rate are stored in the
|
||||
// IR (tts_say) as forward-compat fields — the bridge ignores them until it
|
||||
@@ -242,6 +251,38 @@ export function registerZacusBlocks(Blockly) {
|
||||
stack(this, ZACUS_COLOR.plip); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// Énigmes locales du maître — ces blocs ne produisent pas d'actions : ils
|
||||
// attachent les objets IR `scene` / `puzzle` au step courant (un seul de
|
||||
// chaque par step, voir specs/ZACUS_RUNTIME_3_SPEC.md « Step extras »).
|
||||
def("zacus_scene", function () {
|
||||
this.appendDummyInput().appendField("🖥 Écran maître — titre")
|
||||
.appendField(new Blockly.FieldTextInput("MISSION QR"), "title");
|
||||
this.appendDummyInput().appendField("sous-titre")
|
||||
.appendField(new Blockly.FieldTextInput("scannez dans l'ordre"), "subtitle");
|
||||
this.appendDummyInput().appendField("symbole").appendField(new Blockly.FieldTextInput("RUN"), "symbol")
|
||||
.appendField("effet").appendField(dropdown(ZACUS_SCENE_EFFECTS), "effect");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleQR", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme QR — slot")
|
||||
.appendField(new Blockly.FieldNumber(3, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("codes (dans l'ordre, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("zacus-qr-1, zacus-qr-2"), "codes");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("3"), "fragment");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleSound", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme son — slot")
|
||||
.appendField(new Blockly.FieldNumber(1, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("mélodie (notes MIDI, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("60, 62, 64, 65"), "melody")
|
||||
.appendField("tolérance").appendField(new Blockly.FieldNumber(1, 0, 12, 1), "tolerance");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("1"), "fragment");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
|
||||
// Logique
|
||||
def("zacus_logicIf", function () {
|
||||
this.appendDummyInput().appendField("si").appendField(new Blockly.FieldTextInput("score > 0"), "condition");
|
||||
@@ -276,6 +317,7 @@ export const ZACUS_BLOCK_TYPES = [
|
||||
"zacus_boxIMUShake","zacus_boxIRSend",
|
||||
"zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
|
||||
"zacus_plipRing","zacus_plipPickupWait",
|
||||
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
|
||||
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
|
||||
];
|
||||
|
||||
@@ -314,6 +356,9 @@ export const ZACUS_FIELDS_BY_KIND = {
|
||||
zacus_m5IMUShake: ["threshold","timeout_s"],
|
||||
zacus_plipRing: ["duration_s"],
|
||||
zacus_plipPickupWait: ["timeout_s"],
|
||||
zacus_scene: ["title","subtitle","symbol","effect"],
|
||||
zacus_puzzleQR: ["id","codes","fragment"],
|
||||
zacus_puzzleSound: ["id","melody","tolerance","fragment"],
|
||||
zacus_logicIf: ["condition"],
|
||||
zacus_logicTimer: ["seconds"],
|
||||
zacus_logicScore: ["delta"],
|
||||
@@ -330,5 +375,6 @@ export const ZACUS_TOOLBOX_CATEGORIES = [
|
||||
{ name: "BOX-3", colour: ZACUS_COLOR.box3, types: ["zacus_boxIMUShake","zacus_boxIRSend"] },
|
||||
{ name: "M5", colour: ZACUS_COLOR.m5, types: ["zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake"] },
|
||||
{ name: "PLIP", colour: ZACUS_COLOR.plip, types: ["zacus_plipRing","zacus_plipPickupWait"] },
|
||||
{ name: "Énigmes", colour: ZACUS_COLOR.puzzle, types: ["zacus_scene","zacus_puzzleQR","zacus_puzzleSound"] },
|
||||
{ name: "Logique", colour: ZACUS_COLOR.logic, types: ["zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar"] },
|
||||
];
|
||||
|
||||
@@ -173,7 +173,15 @@ une séquence de hauteurs détectable) est **ignorée avec un avertissement**, s
|
||||
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).
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Blocks studio export: `scene` / `puzzleQR` / `puzzleSound` blocks become
|
||||
step-level `scene` / `puzzle` IR objects (not actions).
|
||||
|
||||
The builder clamps/truncates out-of-range values (with warnings) to the
|
||||
strict gateway limits, so the emitted IR always passes
|
||||
validate_runtime3_document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT / "tools" / "zacus-gateway"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "tools" / "scenario"))
|
||||
|
||||
from blocks_to_runtime3 import compile_blocks # noqa: E402
|
||||
from runtime3_common import validate_runtime3_document # noqa: E402
|
||||
|
||||
|
||||
def _compile(nodes: list[dict]) -> dict:
|
||||
doc = {"blocks_studio_version": 2, "nodes": nodes}
|
||||
return compile_blocks(yaml.safe_dump(doc), scenario_id="TEST_BLOCKS")
|
||||
|
||||
|
||||
def _chain(*nodes: dict) -> list[dict]:
|
||||
"""Assign ids and next pointers to a top-level chain."""
|
||||
out = []
|
||||
for i, node in enumerate(nodes):
|
||||
n = dict(node)
|
||||
n["id"] = f"n{i}"
|
||||
if i + 1 < len(nodes):
|
||||
n["next"] = f"n{i + 1}"
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
START = {"kind": "sceneStart", "params": {"id": "qr"}}
|
||||
SCENE_PARAMS = {"title": "MISSION QR", "subtitle": "scannez dans l'ordre",
|
||||
"symbol": "RUN", "effect": "gyro"}
|
||||
|
||||
|
||||
class SceneBlockExport(unittest.TestCase):
|
||||
def test_scene_block_becomes_step_scene_object(self):
|
||||
ir = _compile(_chain(START, {"kind": "scene", "params": SCENE_PARAMS}))
|
||||
step = ir["steps"][0]
|
||||
self.assertEqual(step["scene"], SCENE_PARAMS)
|
||||
self.assertEqual(step["actions"], [])
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_long_title_truncated_with_warning(self):
|
||||
ir = _compile(_chain(START, {"kind": "scene", "params": {"title": "x" * 80}}))
|
||||
self.assertEqual(ir["steps"][0]["scene"]["title"], "x" * 47)
|
||||
self.assertTrue(any("truncated" in w for w in ir["metadata"]["warnings"]))
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_unknown_effect_falls_back_to_pulse(self):
|
||||
ir = _compile(_chain(START, {"kind": "scene",
|
||||
"params": {"title": "T", "effect": "rainbow"}}))
|
||||
self.assertEqual(ir["steps"][0]["scene"]["effect"], "pulse")
|
||||
self.assertTrue(any("rainbow" in w for w in ir["metadata"]["warnings"]))
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_empty_scene_block_ignored(self):
|
||||
ir = _compile(_chain(START, {"kind": "scene", "params": {}}))
|
||||
self.assertNotIn("scene", ir["steps"][0])
|
||||
self.assertTrue(any("empty scene" in w for w in ir["metadata"]["warnings"]))
|
||||
|
||||
def test_second_scene_block_wins_with_warning(self):
|
||||
ir = _compile(_chain(START,
|
||||
{"kind": "scene", "params": {"title": "A"}},
|
||||
{"kind": "scene", "params": {"title": "B"}}))
|
||||
self.assertEqual(ir["steps"][0]["scene"]["title"], "B")
|
||||
self.assertTrue(any("last one wins" in w for w in ir["metadata"]["warnings"]))
|
||||
|
||||
|
||||
class PuzzleQRBlockExport(unittest.TestCase):
|
||||
def test_codes_parsed_in_order_and_fragment_digits(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {
|
||||
"id": 3, "codes": "zacus-qr-1, zacus-qr-2", "fragment": "12"}}))
|
||||
self.assertEqual(ir["steps"][0]["puzzle"], {
|
||||
"id": 3, "type": "qr",
|
||||
"codes": ["zacus-qr-1", "zacus-qr-2"], "fragment": [1, 2]})
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_fragment_defaults_to_slot_digit(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {
|
||||
"id": 5, "codes": "abc", "fragment": ""}}))
|
||||
self.assertEqual(ir["steps"][0]["puzzle"]["fragment"], [5])
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_without_codes_is_ignored(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {"id": 3, "codes": " "}}))
|
||||
self.assertNotIn("puzzle", ir["steps"][0])
|
||||
self.assertTrue(any("without codes" in w for w in ir["metadata"]["warnings"]))
|
||||
|
||||
def test_long_code_truncated_to_firmware_limit(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {
|
||||
"id": 3, "codes": "y" * 40}}))
|
||||
self.assertEqual(ir["steps"][0]["puzzle"]["codes"], ["y" * 31])
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
|
||||
class PuzzleSoundBlockExport(unittest.TestCase):
|
||||
def test_melody_tolerance_and_fragment(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleSound", "params": {
|
||||
"id": 1, "melody": "60, 62, 64, 65", "tolerance": 2, "fragment": "1"}}))
|
||||
self.assertEqual(ir["steps"][0]["puzzle"], {
|
||||
"id": 1, "type": "sound", "melody": [60, 62, 64, 65],
|
||||
"tolerance": 2, "fragment": [1]})
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_invalid_notes_skipped_with_warning(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleSound", "params": {
|
||||
"id": 1, "melody": "60, la, 300, 62"}}))
|
||||
self.assertEqual(ir["steps"][0]["puzzle"]["melody"], [60, 62])
|
||||
warnings = ir["metadata"]["warnings"]
|
||||
self.assertTrue(any("'la'" in w for w in warnings))
|
||||
self.assertTrue(any("300" in w for w in warnings))
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
def test_unplayable_melody_is_ignored(self):
|
||||
ir = _compile(_chain(START, {"kind": "puzzleSound", "params": {"melody": "la, si"}}))
|
||||
self.assertNotIn("puzzle", ir["steps"][0])
|
||||
self.assertTrue(any("playable melody" in w for w in ir["metadata"]["warnings"]))
|
||||
|
||||
|
||||
class StepLevelOnly(unittest.TestCase):
|
||||
def test_scene_and_puzzle_inside_if_slot_are_ignored(self):
|
||||
nodes = _chain(START, {"kind": "logicIf", "params": {"condition": "score > 0"},
|
||||
"slots": {"body": "s0"}})
|
||||
nodes.append({"id": "s0", "kind": "scene", "params": {"title": "T"}, "next": "s1"})
|
||||
nodes.append({"id": "s1", "kind": "puzzleQR", "params": {"codes": "abc"}})
|
||||
ir = _compile(nodes)
|
||||
step = ir["steps"][0]
|
||||
self.assertNotIn("scene", step)
|
||||
self.assertNotIn("puzzle", step)
|
||||
self.assertEqual(step["actions"][0]["then"], [])
|
||||
self.assertEqual(sum("only makes sense at step level" in w
|
||||
for w in ir["metadata"]["warnings"]), 2)
|
||||
|
||||
def test_full_example_shaped_scenario_validates(self):
|
||||
# Same shape as EXAMPLE_LOCAL_PUZZLES.json, authored as blocks.
|
||||
ir = _compile(_chain(
|
||||
START,
|
||||
{"kind": "scene", "params": SCENE_PARAMS},
|
||||
{"kind": "puzzleQR", "params": {"id": 3, "codes": "zacus-qr-1, zacus-qr-2",
|
||||
"fragment": "5"}},
|
||||
{"kind": "sceneStart", "params": {"id": "sound"}},
|
||||
{"kind": "scene", "params": {"title": "U-SON", "effect": "glitch"}},
|
||||
{"kind": "puzzleSound", "params": {"id": 1, "melody": "60,62,64,65",
|
||||
"tolerance": 2, "fragment": "12"}},
|
||||
))
|
||||
self.assertEqual(len(ir["steps"]), 2)
|
||||
self.assertEqual(ir["steps"][0]["puzzle"]["type"], "qr")
|
||||
self.assertEqual(ir["steps"][1]["puzzle"]["type"], "sound")
|
||||
self.assertEqual(ir["steps"][1]["scene"]["title"], "U-SON")
|
||||
validate_runtime3_document(ir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,6 +27,15 @@ Block-to-IR mapping
|
||||
sub-chains are walked recursively and serialised
|
||||
as nested actions. Branching stays **local to the
|
||||
current step** as per runtime3 spec.
|
||||
- `scene` → step-level `scene` object (master display:
|
||||
title/subtitle/symbol/effect) — NOT an action.
|
||||
- `puzzleQR` / `puzzleSound` → step-level `puzzle` object (local puzzle
|
||||
arming, P3 QR / P1 sound) — NOT an action.
|
||||
Values are clamped/truncated (with warnings) to the
|
||||
strict gateway limits of runtime3_common
|
||||
_validate_step_puzzle/_scene so the emitted IR
|
||||
always validates. One of each per step; a second
|
||||
block overwrites the first with a warning.
|
||||
|
||||
Slot chains never themselves emit new steps — only top-level `sceneStart` does.
|
||||
A `sceneStart` inside a slot is treated as an inline error (warning emitted,
|
||||
@@ -106,6 +115,18 @@ def compile_blocks(yaml_text: str, *, scenario_id: str = "BLOCKS_DRAFT") -> dict
|
||||
steps.append(current)
|
||||
sid = step_id_for(params.get("id"))
|
||||
current = _new_step(sid)
|
||||
elif kind == "scene":
|
||||
scene = _scene_from_params(params, sid, warnings)
|
||||
if scene is not None:
|
||||
if "scene" in current:
|
||||
warnings.append(f"{sid}: multiple scene blocks — last one wins")
|
||||
current["scene"] = scene
|
||||
elif kind in ("puzzleQR", "puzzleSound"):
|
||||
puzzle = _puzzle_from_params(kind, params, sid, warnings)
|
||||
if puzzle is not None:
|
||||
if "puzzle" in current:
|
||||
warnings.append(f"{sid}: multiple puzzle blocks — last one wins")
|
||||
current["puzzle"] = puzzle
|
||||
elif kind == "logicIf":
|
||||
# condition action with then/else sub-action lists
|
||||
then_actions = _walk_slot_actions(node, "body", nodes_by_id, warnings)
|
||||
@@ -355,6 +376,93 @@ def _node_to_action_or_transition(node: dict, warnings: list[str]) -> tuple[str,
|
||||
return None
|
||||
|
||||
|
||||
# ---------- step-level scene / puzzle builders ----------
|
||||
# Limits mirror tools/scenario/runtime3_common.py _validate_step_scene/_puzzle
|
||||
# (themselves a mirror of the firmware's puzzle_binding.h). Out-of-range field
|
||||
# values are clamped/truncated with a warning instead of failing the compile,
|
||||
# so the emitted IR always passes the strict gateway validation.
|
||||
|
||||
_SCENE_LIMITS = {"title": 47, "subtitle": 63, "symbol": 15}
|
||||
_SCENE_EFFECTS = ("pulse", "glitch", "gyro", "none")
|
||||
|
||||
|
||||
def _scene_from_params(params: dict, sid: str, warnings: list[str]) -> dict | None:
|
||||
scene: dict[str, Any] = {}
|
||||
for key, max_len in _SCENE_LIMITS.items():
|
||||
value = str(params.get(key, "") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if len(value) > max_len:
|
||||
warnings.append(f"{sid}: scene.{key} truncated to {max_len} chars")
|
||||
value = value[:max_len]
|
||||
scene[key] = value
|
||||
effect = str(params.get("effect", "") or "").strip()
|
||||
if effect:
|
||||
if effect not in _SCENE_EFFECTS:
|
||||
warnings.append(f"{sid}: scene.effect '{effect}' unknown — using 'pulse'")
|
||||
effect = "pulse"
|
||||
scene["effect"] = effect
|
||||
if not scene:
|
||||
warnings.append(f"{sid}: empty scene block ignored")
|
||||
return None
|
||||
return scene
|
||||
|
||||
|
||||
def _puzzle_from_params(kind: str, params: dict, sid: str, warnings: list[str]) -> dict | None:
|
||||
is_qr = kind == "puzzleQR"
|
||||
pid = _to_int(params.get("id"), default=3 if is_qr else 1)
|
||||
if not (1 <= pid <= 8):
|
||||
warnings.append(f"{sid}: puzzle.id {pid} out of 1..8 — clamped")
|
||||
pid = min(8, max(1, pid))
|
||||
puzzle: dict[str, Any] = {"id": pid, "type": "qr" if is_qr else "sound"}
|
||||
|
||||
if is_qr:
|
||||
codes = [c.strip() for c in str(params.get("codes", "") or "").split(",") if c.strip()]
|
||||
if not codes:
|
||||
warnings.append(f"{sid}: puzzleQR without codes ignored")
|
||||
return None
|
||||
if len(codes) > 16:
|
||||
warnings.append(f"{sid}: puzzle.codes capped at 16 (was {len(codes)})")
|
||||
codes = codes[:16]
|
||||
clipped = [c for c in codes if len(c) >= 32]
|
||||
if clipped:
|
||||
warnings.append(f"{sid}: {len(clipped)} puzzle code(s) truncated to 31 chars")
|
||||
codes = [c[:31] for c in codes]
|
||||
puzzle["codes"] = codes
|
||||
else:
|
||||
melody: list[int] = []
|
||||
for raw in str(params.get("melody", "") or "").split(","):
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
note = int(float(raw))
|
||||
except ValueError:
|
||||
warnings.append(f"{sid}: puzzle melody note '{raw}' is not a number — skipped")
|
||||
continue
|
||||
if not (0 <= note <= 127):
|
||||
warnings.append(f"{sid}: puzzle melody note {note} out of MIDI 0..127 — skipped")
|
||||
continue
|
||||
melody.append(note)
|
||||
if not melody:
|
||||
warnings.append(f"{sid}: puzzleSound without a playable melody ignored")
|
||||
return None
|
||||
if len(melody) > 32:
|
||||
warnings.append(f"{sid}: puzzle.melody capped at 32 notes (was {len(melody)})")
|
||||
melody = melody[:32]
|
||||
puzzle["melody"] = melody
|
||||
puzzle["tolerance"] = max(0, _to_int(params.get("tolerance"), default=1))
|
||||
|
||||
digits = [int(ch) for ch in str(params.get("fragment", "") or "") if ch.isdigit()]
|
||||
if not digits:
|
||||
digits = [pid] # même défaut que translate_solution_to_puzzle
|
||||
elif len(digits) > 4:
|
||||
warnings.append(f"{sid}: puzzle.fragment capped at 4 digits")
|
||||
digits = digits[:4]
|
||||
puzzle["fragment"] = digits
|
||||
return puzzle
|
||||
|
||||
|
||||
def _walk_slot_actions(parent: dict, slot_name: str, nodes_by_id: dict[str, dict], warnings: list[str]) -> list[dict]:
|
||||
"""Walk the chain anchored at `parent.slots[slot_name]` and serialise it as
|
||||
a nested action list. `sceneStart`/`sceneEnd` inside a slot are ignored
|
||||
@@ -368,7 +476,8 @@ def _walk_slot_actions(parent: dict, slot_name: str, nodes_by_id: dict[str, dict
|
||||
out: list[dict] = []
|
||||
for node in chain:
|
||||
kind = node.get("kind", "")
|
||||
if kind in ("sceneStart", "sceneEnd", "sceneGoto", "sceneBranch", "npcIntentMatch", "logicTimer"):
|
||||
if kind in ("sceneStart", "sceneEnd", "sceneGoto", "sceneBranch", "npcIntentMatch", "logicTimer",
|
||||
"scene", "puzzleQR", "puzzleSound"):
|
||||
warnings.append(f"slot '{slot_name}' contains '{kind}' which only makes sense at step level — ignored")
|
||||
continue
|
||||
if kind == "logicIf":
|
||||
|
||||
Reference in New Issue
Block a user