feat(runtime3): condition action + dict actions

Extend the Runtime 3 IR spec with a standalone 'condition' action
kind that takes {expr, then[], else[]} sublists, keeping branching
local to the current step (no extra transitions or step duplication).

The validator now accepts both string-form (legacy firmware import)
and dict-form actions, recursively validating condition.then /
condition.else as nested action lists. STANDARD_ACTION_KINDS gains
the full set of blocks_studio kinds (audio, LCD, hardware, ESP-NOW,
BOX-3, M5, PLIP) so the blocks-to-IR codec output passes validation
end-to-end.
This commit is contained in:
L'électron rare
2026-05-24 10:50:34 +02:00
parent dcbbd07ec9
commit 658ca4ca2a
2 changed files with 87 additions and 0 deletions
+42
View File
@@ -54,3 +54,45 @@ Define a portable runtime artifact that can be compiled from YAML or Blockly-aut
- Entry step is resolved from `scenario.entry_step_id`.
- A simulator can replay deterministic transition events without hardware.
- Firmware adapters may enrich steps with board-specific actions, but not mutate story flow semantics.
## Action Shape
Actions are emitted by authoring tools (blocks studio, firmware import, hand-written YAML) and consumed by runtime adapters. Two equivalent shapes are accepted:
- **String form** (legacy firmware import): `"led_pattern:rainbow"` — opaque, runtime parses at its discretion.
- **Dict form** (preferred for new authoring): `{ "kind": "<name>", ...payload }`.
### Standard action kinds
| `kind` | Payload | Semantics |
|--------|---------|-----------|
| `tts_say` | `text: string` | Speak `text` via the active TTS backend |
| `wait_user_voice` | `timeout_ms: int` | Block until utterance end or timeout |
| `hw_servo` | `channel: int, angle: int` | Set servo on `channel` to `angle` degrees |
| `qr_expect` | `value: string` | Arm the QR matcher with expected payload |
| `led_pattern` | `pattern: string` | Play a named LED pattern |
| `sound_play` | `asset: string` | Play an asset from the media manager |
| `score_add` | `delta: int` | Mutate the run-scoped score variable |
| `set_var` | `name: string, value: string` | Set a scenario variable |
| `condition` | `expr: string, then: [action,…], else: [action,…]` | **Standalone branching.** Evaluate `expr`; execute the `then` action list if truthy, `else` otherwise. Nested `condition` actions are allowed. |
### `condition` action
The `condition` action keeps **branching local to the current step** — no extra transitions or step duplication needed for simple if/else logic. The runtime evaluates `expr` against the current variable scope (left intentionally loose; runtimes that don't implement evaluation MAY treat unknown `expr` as falsy and log it).
Authoring tools that produce `condition` actions (e.g. `BlockKind.logicIf` in the Blocks Studio) MUST place all branch-local actions inside `then` / `else`. Use a transition (event_type `action`, event_name `goto:<target>`) when you need to jump to another step, not a `condition`.
Example:
```json
{
"kind": "condition",
"expr": "score >= 3",
"then": [
{ "kind": "tts_say", "text": "Bravo, niveau suivant." },
{ "kind": "set_var", "name": "unlocked_act2", "value": "true" }
],
"else": [
{ "kind": "tts_say", "text": "Encore un effort." }
]
}
```
+45
View File
@@ -218,6 +218,46 @@ def compile_runtime3_document(data: dict[str, Any], source_kind: str = "yaml") -
}
STANDARD_ACTION_KINDS = {
"tts_say", "wait_user_voice",
"hw_servo", "qr_expect", "led_pattern",
"sound_play", "sound_stop", "sound_volume",
"lcd_text", "lcd_clear", "lcd_image", "lcd_touch_wait",
"buzzer_tone", "relay", "sensor_read", "button_wait",
"espnow_register_peer", "espnow_send", "espnow_broadcast", "espnow_wait",
"box_imu_shake", "box_ir_send",
"m5_beep", "m5_lcd_text", "m5_button_wait", "m5_rgb_led", "m5_imu_shake",
"plip_ring", "plip_pickup_wait",
"score_add", "set_var",
"if_condition", # deprecated alias, kept for older blocks codec output
"condition",
}
def validate_action(action: Any, *, step_id: str, path: str = "actions") -> None:
"""Accept string-form (legacy firmware) or dict-form actions.
Dict-form actions must carry a non-empty `kind`. `condition` actions are
recursively validated to ensure their `then` / `else` lists hold actions.
"""
if isinstance(action, str):
return
if not isinstance(action, dict):
raise ValueError(f"step {step_id} {path}: action must be a string or object")
kind = action.get("kind")
if not isinstance(kind, str) or not kind:
raise ValueError(f"step {step_id} {path}: action missing 'kind'")
if kind == "condition":
if not isinstance(action.get("expr"), str):
raise ValueError(f"step {step_id} {path}: condition.expr must be a string")
for branch in ("then", "else"):
branch_actions = action.get(branch, [])
if not isinstance(branch_actions, list):
raise ValueError(f"step {step_id} {path}.{branch}: must be a list")
for sub_idx, sub in enumerate(branch_actions):
validate_action(sub, step_id=step_id, path=f"{path}.{branch}[{sub_idx}]")
def validate_runtime3_document(document: dict[str, Any]) -> None:
if document.get("schema_version") != "zacus.runtime3.v1":
raise ValueError("schema_version must be zacus.runtime3.v1")
@@ -238,6 +278,11 @@ def validate_runtime3_document(document: dict[str, Any]) -> None:
if step_id in step_ids:
raise ValueError(f"duplicate step id: {step_id}")
step_ids.add(step_id)
actions = step.get("actions", [])
if not isinstance(actions, list):
raise ValueError(f"step {step_id} actions must be a list")
for idx, action in enumerate(actions):
validate_action(action, step_id=step_id, path=f"actions[{idx}]")
transitions = step.get("transitions", [])
if not isinstance(transitions, list):
raise ValueError(f"step {step_id} transitions must be a list")