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:
@@ -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." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user