diff --git a/apps/zacus-hub/Resources/blockly/zacus_blocks.js b/apps/zacus-hub/Resources/blockly/zacus_blocks.js index e9fac63..ed7002c 100644 --- a/apps/zacus-hub/Resources/blockly/zacus_blocks.js +++ b/apps/zacus-hub/Resources/blockly/zacus_blocks.js @@ -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"] }, ]; diff --git a/frontend-v3/packages/shared/blockly/zacus_blocks.d.mts b/frontend-v3/packages/shared/blockly/zacus_blocks.d.mts index 7986d36..b702a31 100644 --- a/frontend-v3/packages/shared/blockly/zacus_blocks.d.mts +++ b/frontend-v3/packages/shared/blockly/zacus_blocks.d.mts @@ -4,6 +4,7 @@ export declare const ZACUS_COLOR: Record; 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; diff --git a/frontend-v3/packages/shared/blockly/zacus_blocks.mjs b/frontend-v3/packages/shared/blockly/zacus_blocks.mjs index 04e8f81..2b2f78c 100644 --- a/frontend-v3/packages/shared/blockly/zacus_blocks.mjs +++ b/frontend-v3/packages/shared/blockly/zacus_blocks.mjs @@ -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"] }, ]; diff --git a/specs/ZACUS_RUNTIME_3_SPEC.md b/specs/ZACUS_RUNTIME_3_SPEC.md index 9a43c96..ae0d55d 100644 --- a/specs/ZACUS_RUNTIME_3_SPEC.md +++ b/specs/ZACUS_RUNTIME_3_SPEC.md @@ -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). diff --git a/tests/runtime3/test_blocks_export_puzzle_scene.py b/tests/runtime3/test_blocks_export_puzzle_scene.py new file mode 100644 index 0000000..83cae60 --- /dev/null +++ b/tests/runtime3/test_blocks_export_puzzle_scene.py @@ -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() diff --git a/tools/zacus-gateway/blocks_to_runtime3.py b/tools/zacus-gateway/blocks_to_runtime3.py index fd6baa7..c538614 100644 --- a/tools/zacus-gateway/blocks_to_runtime3.py +++ b/tools/zacus-gateway/blocks_to_runtime3.py @@ -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":