feat(atelier): board field on morse/nfc/coffre/led/radio puzzles + tests + complete blocks scenario
Repo State / repo-state (pull_request) Failing after 37s
Repo State / repo-state (push) Failing after 27s
Validate Zacus refactor / validate (pull_request) Failing after 5m49s

This commit is contained in:
2026-06-14 17:08:22 +02:00
parent 1b67a4820c
commit 56c44eaf4f
3 changed files with 360 additions and 5 deletions
@@ -386,6 +386,7 @@ export function registerZacusBlocks(Blockly) {
.appendField(new Blockly.FieldTextInput("R1, LED, SW1"), "pattern"); .appendField(new Blockly.FieldTextInput("R1, LED, SW1"), "pattern");
this.appendDummyInput().appendField("fragment (1-4 chiffres)") this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("3"), "fragment"); .appendField(new Blockly.FieldTextInput("3"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle); stack(this, ZACUS_COLOR.puzzle);
}); });
def("zacus_puzzleRadio", function () { 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"); .appendField("± tolérance").appendField(new Blockly.FieldNumber(10, 0, 10000, 1), "tolerance").appendField("Hz");
this.appendDummyInput().appendField("fragment (1-4 chiffres)") this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("4"), "fragment"); .appendField(new Blockly.FieldTextInput("4"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle); stack(this, ZACUS_COLOR.puzzle);
}); });
def("zacus_puzzleMorse", function () { def("zacus_puzzleMorse", function () {
@@ -406,6 +408,7 @@ export function registerZacusBlocks(Blockly) {
.appendField("mode").appendField(dropdown(ZACUS_MORSE_MODES), "mode"); .appendField("mode").appendField(dropdown(ZACUS_MORSE_MODES), "mode");
this.appendDummyInput().appendField("fragment (1-4 chiffres)") this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("5"), "fragment"); .appendField(new Blockly.FieldTextInput("5"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle); stack(this, ZACUS_COLOR.puzzle);
}); });
def("zacus_puzzleNFC", function () { 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("ordre imposé").appendField(new Blockly.FieldCheckbox("TRUE"), "ordered");
this.appendDummyInput().appendField("fragment (1-4 chiffres)") this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("67"), "fragment"); .appendField(new Blockly.FieldTextInput("67"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle); stack(this, ZACUS_COLOR.puzzle);
}); });
def("zacus_puzzleCoffre", function () { def("zacus_puzzleCoffre", function () {
@@ -425,6 +429,7 @@ export function registerZacusBlocks(Blockly) {
.appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres"); .appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres");
this.appendDummyInput().appendField("fragment (1-4 chiffres)") this.appendDummyInput().appendField("fragment (1-4 chiffres)")
.appendField(new Blockly.FieldTextInput("8"), "fragment"); .appendField(new Blockly.FieldTextInput("8"), "fragment");
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
stack(this, ZACUS_COLOR.puzzle); stack(this, ZACUS_COLOR.puzzle);
}); });
// Bloc matériel : lecture d'un tag NFC hors énigme (catégorie Matériel). // 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`). // Phase D2 — binding carte : bloc contexte (slot `body`, champ `board`).
zacus_hwOnBoard: ["board"], zacus_hwOnBoard: ["board"],
// Vague 3 — énigmes manquantes (step-extra `puzzle`). // Vague 3 — énigmes manquantes (step-extra `puzzle`).
zacus_puzzleLED: ["id","pattern","fragment"], zacus_puzzleLED: ["id","pattern","fragment","board"],
zacus_puzzleRadio: ["id","frequency","tolerance","fragment"], zacus_puzzleRadio: ["id","frequency","tolerance","fragment","board"],
zacus_puzzleMorse: ["id","message","mode","fragment"], zacus_puzzleMorse: ["id","message","mode","fragment","board"],
zacus_puzzleNFC: ["id","tags","ordered","fragment"], zacus_puzzleNFC: ["id","tags","ordered","fragment","board"],
zacus_puzzleCoffre: ["id","length","fragment"], zacus_puzzleCoffre: ["id","length","fragment","board"],
// Vague 4 — assemblage du code final (actions). // Vague 4 — assemblage du code final (actions).
zacus_codeAssembly: ["length","fragments"], zacus_codeAssembly: ["length","fragments"],
zacus_codeFragment: ["puzzle","positions"], zacus_codeFragment: ["puzzle","positions"],
+243
View File
@@ -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
@@ -131,6 +131,113 @@ class PuzzleSoundBlockExport(unittest.TestCase):
self.assertTrue(any("playable melody" in w for w in ir["metadata"]["warnings"])) 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): class StepLevelOnly(unittest.TestCase):
def test_scene_and_puzzle_inside_if_slot_are_ignored(self): def test_scene_and_puzzle_inside_if_slot_are_ignored(self):
nodes = _chain(START, {"kind": "logicIf", "params": {"condition": "score > 0"}, nodes = _chain(START, {"kind": "logicIf", "params": {"condition": "score > 0"},