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");
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
def("zacus_logicIf", function () {
@@ -665,7 +671,7 @@ function registerZacusBlocks(Blockly) {
const ZACUS_BLOCK_TYPES = [
"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_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
@@ -700,6 +706,7 @@ const ZACUS_FIELDS_BY_KIND = {
zacus_onSerial: ["token","target"],
zacus_onEspnow: ["command","target"],
zacus_onPuzzleSolved: ["puzzle","target"],
zacus_onCodeAssembled: ["code","target"],
zacus_hwSoundPlay: ["asset"],
zacus_hwAudioStop: [],
zacus_hwAudioVolume: ["level"],
@@ -790,7 +797,7 @@ const ZACUS_REAL_KINDS = new Set([
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
"zacus_hwOnBoard",
"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_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
"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) ──────────────────
{ 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: "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: "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"] },
@@ -516,6 +516,12 @@ export function registerZacusBlocks(Blockly) {
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
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
def("zacus_logicIf", function () {
@@ -661,7 +667,7 @@ export function registerZacusBlocks(Blockly) {
export const ZACUS_BLOCK_TYPES = [
"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_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
@@ -696,6 +702,7 @@ export const ZACUS_FIELDS_BY_KIND = {
zacus_onSerial: ["token","target"],
zacus_onEspnow: ["command","target"],
zacus_onPuzzleSolved: ["puzzle","target"],
zacus_onCodeAssembled: ["code","target"],
zacus_hwSoundPlay: ["asset"],
zacus_hwAudioStop: [],
zacus_hwAudioVolume: ["level"],
@@ -786,7 +793,7 @@ export const ZACUS_REAL_KINDS = new Set([
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
"zacus_hwOnBoard",
"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_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
"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) ──────────────────
{ 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: "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: "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"] },
@@ -9,6 +9,7 @@ Mapping (blocks_to_runtime3.py):
onSerial -> {event_type: serial, event_name: <token>}
onEspnow -> {event_type: espnow, event_name: <command>}
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).
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")
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):
for kind, params in (
("onButton", {"button": "A", "target": "fin"}),
@@ -141,6 +154,7 @@ class TransitionBlockExport(unittest.TestCase):
("onSerial", {"token": "x", "target": "fin"}),
("onEspnow", {"command": "x", "target": "fin"}),
("onPuzzleSolved", {"puzzle": "2", "target": "fin"}),
("onCodeAssembled", {"code": "1337", "target": "fin"}),
):
ir = _compile(_chain(START, {"kind": kind, "params": params}, TARGET))
t = _only_transition(ir)
+17 -4
View File
@@ -25,6 +25,9 @@ Block-to-IR mapping
- `onPuzzleSolved`→ `{event_type: unlock, event_name: "puzzle:<id>"}`
(le « puzzle résolu » se modélise comme un unlock nommé,
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}`.
- `hwReadQR` → action `{kind: qr_expect, value}` (+ serial transition).
- `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
# chemin firmware-import ; le chemin blocks_studio émet STEP_<target> brut,
# 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()
if not target:
warnings.append(f"{kind} without 'target' ignored")
@@ -690,8 +693,9 @@ def _node_to_action_or_transition(
"target_step_id": target_step_id,
"priority": 5,
})
# onPuzzleSolved : « énigme résolue » se modélise comme un unlock nommé
# (event_type unlock, event_name puzzle:<id>) — le firmware n'expose pas
if kind == "onPuzzleSolved":
# « énigme résolue » se modélise comme un unlock nommé (event_type
# 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",
@@ -699,6 +703,15 @@ def _node_to_action_or_transition(
"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", {
"event_type": "unlock",
"event_name": f"code:{str(params.get('code', '') or '').strip()}",
"target_step_id": target_step_id,
"priority": 5,
})
if kind == "sceneGoto":
target = params.get("target", "").strip()
return ("transitions", {
@@ -900,7 +913,7 @@ def _walk_slot_actions(parent: dict, slot_name: str, nodes_by_id: dict[str, dict
kind = node.get("kind", "")
_warn_future_hw(kind, warnings, future_hw_seen)
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",
"puzzleLED", "puzzleRadio", "puzzleMorse", "puzzleNFC", "puzzleCoffre",
"phase", "setParcours", "scorePenalty"):