feat(blockly): onCodeAssembled transition block (completes D4 puzzle-state branching)
Repo State / repo-state (push) Failing after 34s
Repo State / repo-state (pull_request) Failing after 37s
Validate Zacus refactor / validate (pull_request) Failing after 5m38s

This commit is contained in:
2026-06-14 11:13:45 +02:00
parent b22ec686fd
commit 46eebd0066
4 changed files with 53 additions and 12 deletions
@@ -520,6 +520,12 @@ function registerZacusBlocks(Blockly) {
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target"); .appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true); stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
}); });
def("zacus_onCodeAssembled", function () {
this.appendDummyInput().appendField("Quand code assemblé")
.appendField(new Blockly.FieldTextInput("4242"), "code")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
// Logique // Logique
def("zacus_logicIf", function () { def("zacus_logicIf", function () {
@@ -665,7 +671,7 @@ function registerZacusBlocks(Blockly) {
const ZACUS_BLOCK_TYPES = [ const ZACUS_BLOCK_TYPES = [
"zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch", "zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved", "zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch", "zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay", "zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait", "zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
@@ -700,6 +706,7 @@ const ZACUS_FIELDS_BY_KIND = {
zacus_onSerial: ["token","target"], zacus_onSerial: ["token","target"],
zacus_onEspnow: ["command","target"], zacus_onEspnow: ["command","target"],
zacus_onPuzzleSolved: ["puzzle","target"], zacus_onPuzzleSolved: ["puzzle","target"],
zacus_onCodeAssembled: ["code","target"],
zacus_hwSoundPlay: ["asset"], zacus_hwSoundPlay: ["asset"],
zacus_hwAudioStop: [], zacus_hwAudioStop: [],
zacus_hwAudioVolume: ["level"], zacus_hwAudioVolume: ["level"],
@@ -790,7 +797,7 @@ const ZACUS_REAL_KINDS = new Set([
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound", "zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
"zacus_hwOnBoard", "zacus_hwOnBoard",
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch", "zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved", "zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay", "zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait", "zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar", "zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
@@ -819,7 +826,7 @@ const ZACUS_TOOLBOX_CATEGORIES = [
// ── Voie réelle (exécutée on-device ou côté gateway) ────────────────── // ── Voie réelle (exécutée on-device ou côté gateway) ──────────────────
{ name: "Cartes", colour: ZACUS_COLOR.hardware, types: ["zacus_hwOnBoard"] }, { name: "Cartes", colour: ZACUS_COLOR.hardware, types: ["zacus_hwOnBoard"] },
{ name: "Scènes", colour: ZACUS_COLOR.scene, types: ["zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch"] }, { name: "Scènes", colour: ZACUS_COLOR.scene, types: ["zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch"] },
{ name: "Transitions", colour: ZACUS_COLOR.scene, types: ["zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved"] }, { name: "Transitions", colour: ZACUS_COLOR.scene, types: ["zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled"] },
{ name: "NPC voix", colour: ZACUS_COLOR.npc, types: ["zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch"] }, { name: "NPC voix", colour: ZACUS_COLOR.npc, types: ["zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch"] },
{ name: "Audio", colour: ZACUS_COLOR.audio, types: ["zacus_mediaPlay","zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume"] }, { name: "Audio", colour: ZACUS_COLOR.audio, types: ["zacus_mediaPlay","zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume"] },
{ name: "ESP-NOW", colour: ZACUS_COLOR.espnow, types: ["zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait"] }, { name: "ESP-NOW", colour: ZACUS_COLOR.espnow, types: ["zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait"] },
@@ -516,6 +516,12 @@ export function registerZacusBlocks(Blockly) {
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target"); .appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true); stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
}); });
def("zacus_onCodeAssembled", function () {
this.appendDummyInput().appendField("Quand code assemblé")
.appendField(new Blockly.FieldTextInput("4242"), "code")
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
});
// Logique // Logique
def("zacus_logicIf", function () { def("zacus_logicIf", function () {
@@ -661,7 +667,7 @@ export function registerZacusBlocks(Blockly) {
export const ZACUS_BLOCK_TYPES = [ export const ZACUS_BLOCK_TYPES = [
"zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch", "zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved", "zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch", "zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay", "zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait", "zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
@@ -696,6 +702,7 @@ export const ZACUS_FIELDS_BY_KIND = {
zacus_onSerial: ["token","target"], zacus_onSerial: ["token","target"],
zacus_onEspnow: ["command","target"], zacus_onEspnow: ["command","target"],
zacus_onPuzzleSolved: ["puzzle","target"], zacus_onPuzzleSolved: ["puzzle","target"],
zacus_onCodeAssembled: ["code","target"],
zacus_hwSoundPlay: ["asset"], zacus_hwSoundPlay: ["asset"],
zacus_hwAudioStop: [], zacus_hwAudioStop: [],
zacus_hwAudioVolume: ["level"], zacus_hwAudioVolume: ["level"],
@@ -786,7 +793,7 @@ export const ZACUS_REAL_KINDS = new Set([
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound", "zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
"zacus_hwOnBoard", "zacus_hwOnBoard",
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch", "zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved", "zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay", "zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait", "zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar", "zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
@@ -815,7 +822,7 @@ export const ZACUS_TOOLBOX_CATEGORIES = [
// ── Voie réelle (exécutée on-device ou côté gateway) ────────────────── // ── Voie réelle (exécutée on-device ou côté gateway) ──────────────────
{ name: "Cartes", colour: ZACUS_COLOR.hardware, types: ["zacus_hwOnBoard"] }, { name: "Cartes", colour: ZACUS_COLOR.hardware, types: ["zacus_hwOnBoard"] },
{ name: "Scènes", colour: ZACUS_COLOR.scene, types: ["zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch"] }, { name: "Scènes", colour: ZACUS_COLOR.scene, types: ["zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch"] },
{ name: "Transitions", colour: ZACUS_COLOR.scene, types: ["zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved"] }, { name: "Transitions", colour: ZACUS_COLOR.scene, types: ["zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled"] },
{ name: "NPC voix", colour: ZACUS_COLOR.npc, types: ["zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch"] }, { name: "NPC voix", colour: ZACUS_COLOR.npc, types: ["zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch"] },
{ name: "Audio", colour: ZACUS_COLOR.audio, types: ["zacus_mediaPlay","zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume"] }, { name: "Audio", colour: ZACUS_COLOR.audio, types: ["zacus_mediaPlay","zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume"] },
{ name: "ESP-NOW", colour: ZACUS_COLOR.espnow, types: ["zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait"] }, { name: "ESP-NOW", colour: ZACUS_COLOR.espnow, types: ["zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait"] },
@@ -9,6 +9,7 @@ Mapping (blocks_to_runtime3.py):
onSerial -> {event_type: serial, event_name: <token>} onSerial -> {event_type: serial, event_name: <token>}
onEspnow -> {event_type: espnow, event_name: <command>} onEspnow -> {event_type: espnow, event_name: <command>}
onPuzzleSolved -> {event_type: unlock, event_name: "puzzle:<id>"} onPuzzleSolved -> {event_type: unlock, event_name: "puzzle:<id>"}
onCodeAssembled-> {event_type: unlock, event_name: "code:<code>"}
Each carries target_step_id = STEP_<target> (same construction as sceneGoto). Each carries target_step_id = STEP_<target> (same construction as sceneGoto).
EVENT_TYPES is unchanged by D4 — these event_types are all already valid, the EVENT_TYPES is unchanged by D4 — these event_types are all already valid, the
@@ -133,6 +134,18 @@ class TransitionBlockExport(unittest.TestCase):
self.assertEqual(t["target_step_id"], "STEP_fin") self.assertEqual(t["target_step_id"], "STEP_fin")
validate_runtime3_document(ir) validate_runtime3_document(ir)
def test_on_code_assembled_models_as_named_unlock(self):
ir = _compile(_chain(
START,
{"kind": "onCodeAssembled", "params": {"code": "4242", "target": "fin"}},
TARGET,
))
t = _only_transition(ir)
self.assertEqual(t["event_type"], "unlock")
self.assertEqual(t["event_name"], "code:4242")
self.assertEqual(t["target_step_id"], "STEP_fin")
validate_runtime3_document(ir)
def test_all_event_types_are_valid(self): def test_all_event_types_are_valid(self):
for kind, params in ( for kind, params in (
("onButton", {"button": "A", "target": "fin"}), ("onButton", {"button": "A", "target": "fin"}),
@@ -141,6 +154,7 @@ class TransitionBlockExport(unittest.TestCase):
("onSerial", {"token": "x", "target": "fin"}), ("onSerial", {"token": "x", "target": "fin"}),
("onEspnow", {"command": "x", "target": "fin"}), ("onEspnow", {"command": "x", "target": "fin"}),
("onPuzzleSolved", {"puzzle": "2", "target": "fin"}), ("onPuzzleSolved", {"puzzle": "2", "target": "fin"}),
("onCodeAssembled", {"code": "1337", "target": "fin"}),
): ):
ir = _compile(_chain(START, {"kind": kind, "params": params}, TARGET)) ir = _compile(_chain(START, {"kind": kind, "params": params}, TARGET))
t = _only_transition(ir) t = _only_transition(ir)
+19 -6
View File
@@ -25,6 +25,9 @@ Block-to-IR mapping
- `onPuzzleSolved`→ `{event_type: unlock, event_name: "puzzle:<id>"}` - `onPuzzleSolved`→ `{event_type: unlock, event_name: "puzzle:<id>"}`
(le « puzzle résolu » se modélise comme un unlock nommé, (le « puzzle résolu » se modélise comme un unlock nommé,
EVENT_TYPES n'ayant pas d'event puzzle_state dédié). EVENT_TYPES n'ayant pas d'event puzzle_state dédié).
- `onCodeAssembled`→ `{event_type: unlock, event_name: "code:<code>"}`
(le « code assemblé » se modélise comme un unlock nommé,
même famille que onPuzzleSolved — complète le set D4).
- `hwServo` → action `{kind: hw_servo, channel, angle}`. - `hwServo` → action `{kind: hw_servo, channel, angle}`.
- `hwReadQR` → action `{kind: qr_expect, value}` (+ serial transition). - `hwReadQR` → action `{kind: qr_expect, value}` (+ serial transition).
- `hwLEDPattern` → action `{kind: led_pattern, pattern}`. - `hwLEDPattern` → action `{kind: led_pattern, pattern}`.
@@ -649,7 +652,7 @@ def _node_to_action_or_transition(
# stricte des ids vit côté runtime3_common._normalize_transition pour le # stricte des ids vit côté runtime3_common._normalize_transition pour le
# chemin firmware-import ; le chemin blocks_studio émet STEP_<target> brut, # chemin firmware-import ; le chemin blocks_studio émet STEP_<target> brut,
# cohérent avec les transitions existantes). target vide → bloc ignoré. # cohérent avec les transitions existantes). target vide → bloc ignoré.
if kind in ("onButton", "onAudioDone", "onUnlock", "onSerial", "onEspnow", "onPuzzleSolved"): if kind in ("onButton", "onAudioDone", "onUnlock", "onSerial", "onEspnow", "onPuzzleSolved", "onCodeAssembled"):
target = str(params.get("target", "") or "").strip() target = str(params.get("target", "") or "").strip()
if not target: if not target:
warnings.append(f"{kind} without 'target' ignored") warnings.append(f"{kind} without 'target' ignored")
@@ -690,12 +693,22 @@ def _node_to_action_or_transition(
"target_step_id": target_step_id, "target_step_id": target_step_id,
"priority": 5, "priority": 5,
}) })
# onPuzzleSolved : « énigme résolue » se modélise comme un unlock nommé if kind == "onPuzzleSolved":
# (event_type unlock, event_name puzzle:<id>) — le firmware n'expose pas # « énigme résolue » se modélise comme un unlock nommé (event_type
# d'event_type puzzle_state dédié, et EVENT_TYPES ne s'étend pas en D4. # unlock, event_name puzzle:<id>) — le firmware n'expose pas
# d'event_type puzzle_state dédié, et EVENT_TYPES ne s'étend pas en D4.
return ("transitions", {
"event_type": "unlock",
"event_name": f"puzzle:{str(params.get('puzzle', '') or '').strip()}",
"target_step_id": target_step_id,
"priority": 5,
})
# onCodeAssembled : « code assemblé » se modélise comme un unlock nommé
# (event_type unlock, event_name code:<code>) — même famille que
# onPuzzleSolved, complète le branchement D4 sur l'état des énigmes.
return ("transitions", { return ("transitions", {
"event_type": "unlock", "event_type": "unlock",
"event_name": f"puzzle:{str(params.get('puzzle', '') or '').strip()}", "event_name": f"code:{str(params.get('code', '') or '').strip()}",
"target_step_id": target_step_id, "target_step_id": target_step_id,
"priority": 5, "priority": 5,
}) })
@@ -900,7 +913,7 @@ def _walk_slot_actions(parent: dict, slot_name: str, nodes_by_id: dict[str, dict
kind = node.get("kind", "") kind = node.get("kind", "")
_warn_future_hw(kind, warnings, future_hw_seen) _warn_future_hw(kind, warnings, future_hw_seen)
if kind in ("sceneStart", "sceneEnd", "sceneGoto", "sceneBranch", "npcIntentMatch", "logicTimer", if kind in ("sceneStart", "sceneEnd", "sceneGoto", "sceneBranch", "npcIntentMatch", "logicTimer",
"onButton", "onAudioDone", "onUnlock", "onSerial", "onEspnow", "onPuzzleSolved", "onButton", "onAudioDone", "onUnlock", "onSerial", "onEspnow", "onPuzzleSolved", "onCodeAssembled",
"scene", "puzzleQR", "puzzleSound", "scene", "puzzleQR", "puzzleSound",
"puzzleLED", "puzzleRadio", "puzzleMorse", "puzzleNFC", "puzzleCoffre", "puzzleLED", "puzzleRadio", "puzzleMorse", "puzzleNFC", "puzzleCoffre",
"phase", "setParcours", "scorePenalty"): "phase", "setParcours", "scorePenalty"):