From 56c44eaf4f66792bd99d923b2ca59a43a1acbf6e Mon Sep 17 00:00:00 2001 From: electron-rare Date: Sun, 14 Jun 2026 17:08:22 +0200 Subject: [PATCH] feat(atelier): board field on morse/nfc/coffre/led/radio puzzles + tests + complete blocks scenario --- .../packages/shared/blockly/zacus_blocks.mjs | 15 +- game/scenarios/example_blocks_complete.yaml | 243 ++++++++++++++++++ .../test_blocks_export_puzzle_scene.py | 107 ++++++++ 3 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 game/scenarios/example_blocks_complete.yaml diff --git a/frontend-v3/packages/shared/blockly/zacus_blocks.mjs b/frontend-v3/packages/shared/blockly/zacus_blocks.mjs index 944df96..1e30642 100644 --- a/frontend-v3/packages/shared/blockly/zacus_blocks.mjs +++ b/frontend-v3/packages/shared/blockly/zacus_blocks.mjs @@ -386,6 +386,7 @@ export function registerZacusBlocks(Blockly) { .appendField(new Blockly.FieldTextInput("R1, LED, SW1"), "pattern"); this.appendDummyInput().appendField("fragment (1-4 chiffres)") .appendField(new Blockly.FieldTextInput("3"), "fragment"); + this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board"); stack(this, ZACUS_COLOR.puzzle); }); def("zacus_puzzleRadio", function () { @@ -396,6 +397,7 @@ export function registerZacusBlocks(Blockly) { .appendField("± tolérance").appendField(new Blockly.FieldNumber(10, 0, 10000, 1), "tolerance").appendField("Hz"); this.appendDummyInput().appendField("fragment (1-4 chiffres)") .appendField(new Blockly.FieldTextInput("4"), "fragment"); + this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board"); stack(this, ZACUS_COLOR.puzzle); }); def("zacus_puzzleMorse", function () { @@ -406,6 +408,7 @@ export function registerZacusBlocks(Blockly) { .appendField("mode").appendField(dropdown(ZACUS_MORSE_MODES), "mode"); this.appendDummyInput().appendField("fragment (1-4 chiffres)") .appendField(new Blockly.FieldTextInput("5"), "fragment"); + this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board"); stack(this, ZACUS_COLOR.puzzle); }); def("zacus_puzzleNFC", function () { @@ -416,6 +419,7 @@ export function registerZacusBlocks(Blockly) { this.appendDummyInput().appendField("ordre imposé").appendField(new Blockly.FieldCheckbox("TRUE"), "ordered"); this.appendDummyInput().appendField("fragment (1-4 chiffres)") .appendField(new Blockly.FieldTextInput("67"), "fragment"); + this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board"); stack(this, ZACUS_COLOR.puzzle); }); def("zacus_puzzleCoffre", function () { @@ -425,6 +429,7 @@ export function registerZacusBlocks(Blockly) { .appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres"); this.appendDummyInput().appendField("fragment (1-4 chiffres)") .appendField(new Blockly.FieldTextInput("8"), "fragment"); + this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board"); stack(this, ZACUS_COLOR.puzzle); }); // Bloc matériel : lecture d'un tag NFC hors énigme (catégorie Matériel). @@ -739,11 +744,11 @@ export const ZACUS_FIELDS_BY_KIND = { // Phase D2 — binding carte : bloc contexte (slot `body`, champ `board`). zacus_hwOnBoard: ["board"], // Vague 3 — énigmes manquantes (step-extra `puzzle`). - zacus_puzzleLED: ["id","pattern","fragment"], - zacus_puzzleRadio: ["id","frequency","tolerance","fragment"], - zacus_puzzleMorse: ["id","message","mode","fragment"], - zacus_puzzleNFC: ["id","tags","ordered","fragment"], - zacus_puzzleCoffre: ["id","length","fragment"], + zacus_puzzleLED: ["id","pattern","fragment","board"], + zacus_puzzleRadio: ["id","frequency","tolerance","fragment","board"], + zacus_puzzleMorse: ["id","message","mode","fragment","board"], + zacus_puzzleNFC: ["id","tags","ordered","fragment","board"], + zacus_puzzleCoffre: ["id","length","fragment","board"], // Vague 4 — assemblage du code final (actions). zacus_codeAssembly: ["length","fragments"], zacus_codeFragment: ["puzzle","positions"], diff --git a/game/scenarios/example_blocks_complete.yaml b/game/scenarios/example_blocks_complete.yaml new file mode 100644 index 0000000..d0ac6d4 --- /dev/null +++ b/game/scenarios/example_blocks_complete.yaml @@ -0,0 +1,243 @@ +blocks_studio_version: 2 +# Complete blocks v2 scenario: all 7 puzzles + code assembly + transitions. +# Puzzles: P1 Sound, P2 LED, P3 QR, P4 Radio, P5 Morse, P6 NFC, P7 Coffre. +# Boards: sound/qr on master, led/radio on master (future-hw, no real board yet), +# morse/nfc on plip, coffre on p7_coffre. +# FUTURE_HW warnings expected (non-blocking) for led, radio, morse, nfc, coffre. +nodes: + + # ── Step 1 : Sound puzzle (P1) ────────────────────────────────────── + - id: start_sound + kind: sceneStart + params: {id: sound} + next: scene_sound + + - id: scene_sound + kind: scene + params: + title: "MISSION SON" + subtitle: "reproduisez la mélodie" + symbol: "NOTE" + effect: pulse + next: puzzle_sound + + - id: puzzle_sound + kind: puzzleSound + params: + id: 1 + melody: "60, 62, 64, 65" + tolerance: 2 + fragment: "1" + board: master + next: on_sound_solved + + - id: on_sound_solved + kind: onPuzzleSolved + params: + puzzle: "1" + target: qr + next: null + + # ── Step 2 : QR puzzle (P3) ───────────────────────────────────────── + - id: start_qr + kind: sceneStart + params: {id: qr} + next: scene_qr + + - id: scene_qr + kind: scene + params: + title: "MISSION QR" + subtitle: "scannez dans l'ordre" + symbol: "RUN" + effect: gyro + next: puzzle_qr + + - id: puzzle_qr + kind: puzzleQR + params: + id: 3 + codes: "zacus-qr-1, zacus-qr-2" + fragment: "3" + board: master + next: on_qr_solved + + - id: on_qr_solved + kind: onPuzzleSolved + params: + puzzle: "3" + target: led + + # ── Step 3 : LED puzzle (P2) ──────────────────────────────────────── + - id: start_led + kind: sceneStart + params: {id: led} + next: scene_led + + - id: scene_led + kind: scene + params: + title: "MISSION CIRCUIT" + subtitle: "assemblez le circuit" + symbol: "LED" + effect: pulse + next: puzzle_led + + - id: puzzle_led + kind: puzzleLED + params: + id: 2 + pattern: "R1, LED, SW1" + fragment: "2" + board: master + next: on_led_solved + + - id: on_led_solved + kind: onPuzzleSolved + params: + puzzle: "2" + target: radio + + # ── Step 4 : Radio puzzle (P4) ────────────────────────────────────── + - id: start_radio + kind: sceneStart + params: {id: radio} + next: scene_radio + + - id: scene_radio + kind: scene + params: + title: "MISSION RADIO" + subtitle: "trouvez la fréquence" + symbol: "WAVE" + effect: glitch + next: puzzle_radio + + - id: puzzle_radio + kind: puzzleRadio + params: + id: 4 + frequency: 1337 + tolerance: 10 + fragment: "4" + board: master + next: on_radio_solved + + - id: on_radio_solved + kind: onPuzzleSolved + params: + puzzle: "4" + target: morse + + # ── Step 5 : Morse puzzle (P5) ────────────────────────────────────── + - id: start_morse + kind: sceneStart + params: {id: morse} + next: scene_morse + + - id: scene_morse + kind: scene + params: + title: "MISSION MORSE" + subtitle: "décodez le message" + symbol: "DOT" + effect: pulse + next: puzzle_morse + + - id: puzzle_morse + kind: puzzleMorse + params: + id: 5 + message: "ZACUS" + mode: normal + fragment: "5" + board: plip + next: on_morse_solved + + - id: on_morse_solved + kind: onPuzzleSolved + params: + puzzle: "5" + target: nfc + + # ── Step 6 : NFC puzzle (P6) ──────────────────────────────────────── + - id: start_nfc + kind: sceneStart + params: {id: nfc} + next: scene_nfc + + - id: scene_nfc + kind: scene + params: + title: "MISSION SYMBOLES" + subtitle: "placez les tags NFC" + symbol: "NFC" + effect: pulse + next: puzzle_nfc + + - id: puzzle_nfc + kind: puzzleNFC + params: + id: 6 + tags: "7, 2, 11, 4, 9, 1, 8, 3, 12, 6, 10, 5" + ordered: "TRUE" + fragment: "67" + board: plip + next: on_nfc_solved + + - id: on_nfc_solved + kind: onPuzzleSolved + params: + puzzle: "6" + target: coffre + + # ── Step 7 : Coffre puzzle (P7) + code assembly ───────────────────── + - id: start_coffre + kind: sceneStart + params: {id: coffre} + next: scene_coffre + + - id: scene_coffre + kind: scene + params: + title: "LE COFFRE" + subtitle: "entrez le code final" + symbol: "LOCK" + effect: glitch + next: puzzle_coffre + + - id: puzzle_coffre + kind: puzzleCoffre + params: + id: 7 + length: 8 + fragment: "8" + board: p7_coffre + next: assemble_code + + - id: assemble_code + kind: codeAssembly + params: + length: 8 + fragments: "P1_SON:1, P3_QR:3, P2_CIRCUIT:2, P4_RADIO:4, P5_MORSE:5, P6_SYMBOLES:6-7, P7_COFFRE:8" + next: on_code_assembled + + - id: on_code_assembled + kind: onCodeAssembled + params: + code: "" + target: outro + + # ── Step 8 : Outro ────────────────────────────────────────────────── + - id: start_outro + kind: sceneStart + params: {id: outro} + next: scene_outro + + - id: scene_outro + kind: scene + params: + title: "VICTOIRE" + subtitle: "le mystere est resolu" + symbol: "WIN" + effect: gyro diff --git a/tests/runtime3/test_blocks_export_puzzle_scene.py b/tests/runtime3/test_blocks_export_puzzle_scene.py index 9f427a3..1c4d7c8 100644 --- a/tests/runtime3/test_blocks_export_puzzle_scene.py +++ b/tests/runtime3/test_blocks_export_puzzle_scene.py @@ -131,6 +131,113 @@ class PuzzleSoundBlockExport(unittest.TestCase): self.assertTrue(any("playable melody" in w for w in ir["metadata"]["warnings"])) +class PuzzleLEDBlockExport(unittest.TestCase): + def test_pattern_and_fragment_and_board_default(self): + ir = _compile(_chain(START, {"kind": "puzzleLED", "params": { + "id": 2, "pattern": "R1, LED, SW1", "fragment": "3"}})) + self.assertEqual(ir["steps"][0]["puzzle"], { + "id": 2, "type": "led", + "pattern": ["R1", "LED", "SW1"], "fragment": [3], + "board": "master"}) + validate_runtime3_document(ir) + + def test_board_explicit_plip(self): + ir = _compile(_chain(START, {"kind": "puzzleLED", "params": { + "id": 2, "pattern": "R1, LED", "fragment": "3", "board": "plip"}})) + self.assertEqual(ir["steps"][0]["puzzle"]["board"], "plip") + validate_runtime3_document(ir) + + def test_without_pattern_ignored(self): + ir = _compile(_chain(START, {"kind": "puzzleLED", "params": {"id": 2, "pattern": " "}})) + self.assertNotIn("puzzle", ir["steps"][0]) + self.assertTrue(any("without a pattern" in w for w in ir["metadata"]["warnings"])) + + +class PuzzleRadioBlockExport(unittest.TestCase): + def test_frequency_tolerance_fragment_board_default(self): + ir = _compile(_chain(START, {"kind": "puzzleRadio", "params": { + "id": 4, "frequency": 1337, "tolerance": 10, "fragment": "4"}})) + self.assertEqual(ir["steps"][0]["puzzle"], { + "id": 4, "type": "radio", + "frequency": 1337, "tolerance": 10, "fragment": [4], + "board": "master"}) + validate_runtime3_document(ir) + + def test_board_explicit_box3(self): + ir = _compile(_chain(START, {"kind": "puzzleRadio", "params": { + "id": 4, "frequency": 440, "tolerance": 5, "fragment": "4", "board": "box3"}})) + self.assertEqual(ir["steps"][0]["puzzle"]["board"], "box3") + validate_runtime3_document(ir) + + +class PuzzleMorseBlockExport(unittest.TestCase): + def test_message_mode_fragment_board_default(self): + ir = _compile(_chain(START, {"kind": "puzzleMorse", "params": { + "id": 5, "message": "ZACUS", "mode": "normal", "fragment": "5"}})) + self.assertEqual(ir["steps"][0]["puzzle"], { + "id": 5, "type": "morse", + "message": "ZACUS", "mode": "normal", "fragment": [5], + "board": "master"}) + validate_runtime3_document(ir) + + def test_board_explicit_plip(self): + ir = _compile(_chain(START, {"kind": "puzzleMorse", "params": { + "id": 5, "message": "SOS", "mode": "light", "fragment": "5", "board": "plip"}})) + self.assertEqual(ir["steps"][0]["puzzle"]["board"], "plip") + validate_runtime3_document(ir) + + def test_without_message_ignored(self): + ir = _compile(_chain(START, {"kind": "puzzleMorse", "params": {"id": 5, "message": ""}})) + self.assertNotIn("puzzle", ir["steps"][0]) + self.assertTrue(any("without a message" in w for w in ir["metadata"]["warnings"])) + + +class PuzzleNFCBlockExport(unittest.TestCase): + def test_tags_ordered_fragment_board_default(self): + ir = _compile(_chain(START, {"kind": "puzzleNFC", "params": { + "id": 6, "tags": "7, 2, 11", "ordered": "TRUE", "fragment": "67"}})) + self.assertEqual(ir["steps"][0]["puzzle"], { + "id": 6, "type": "nfc", + "tags": ["7", "2", "11"], "ordered": True, "fragment": [6, 7], + "board": "master"}) + validate_runtime3_document(ir) + + def test_board_explicit_plip(self): + ir = _compile(_chain(START, {"kind": "puzzleNFC", "params": { + "id": 6, "tags": "1, 2, 3", "ordered": "TRUE", "fragment": "6", "board": "plip"}})) + self.assertEqual(ir["steps"][0]["puzzle"]["board"], "plip") + validate_runtime3_document(ir) + + def test_without_tags_ignored(self): + ir = _compile(_chain(START, {"kind": "puzzleNFC", "params": {"id": 6, "tags": ""}})) + self.assertNotIn("puzzle", ir["steps"][0]) + self.assertTrue(any("without tags" in w for w in ir["metadata"]["warnings"])) + + +class PuzzleCoffreBlockExport(unittest.TestCase): + def test_length_fragment_board_default(self): + ir = _compile(_chain(START, {"kind": "puzzleCoffre", "params": { + "id": 7, "length": 8, "fragment": "8"}})) + self.assertEqual(ir["steps"][0]["puzzle"], { + "id": 7, "type": "keypad", + "length": 8, "fragment": [8], + "board": "master"}) + validate_runtime3_document(ir) + + def test_board_explicit_p7_coffre(self): + ir = _compile(_chain(START, {"kind": "puzzleCoffre", "params": { + "id": 7, "length": 6, "fragment": "8", "board": "p7_coffre"}})) + self.assertEqual(ir["steps"][0]["puzzle"]["board"], "p7_coffre") + validate_runtime3_document(ir) + + def test_unknown_board_falls_back_master(self): + ir = _compile(_chain(START, {"kind": "puzzleCoffre", "params": { + "id": 7, "length": 8, "fragment": "8", "board": "bad_board"}})) + self.assertEqual(ir["steps"][0]["puzzle"]["board"], "master") + self.assertTrue(any("board 'bad_board'" in w for w in ir["metadata"]["warnings"])) + validate_runtime3_document(ir) + + class StepLevelOnly(unittest.TestCase): def test_scene_and_puzzle_inside_if_slot_are_ignored(self): nodes = _chain(START, {"kind": "logicIf", "params": {"condition": "score > 0"},