chore: repo coherence pass (review, docs, validators, exports, manifests)

This commit is contained in:
Clément SAILLANT
2026-02-14 14:43:21 +01:00
parent ecf15a4d64
commit b835e6c5c7
31 changed files with 786 additions and 15 deletions
+17
View File
@@ -3,3 +3,20 @@
# Local IDE settings
.vscode/
# Generated game exports
game/exports/*
!game/exports/.gitkeep
!game/exports/previews/
# Locally generated audio/render outputs (not versioned)
audio/generated/*
!audio/generated/README.md
# Locally generated printable PDFs (not versioned)
printables/export/pdf/**
!printables/export/pdf/zacus_v1/README.md
# Locally generated printable PNGs (not versioned)
printables/export/png/**
!printables/export/png/zacus_v1/.gitkeep
+2 -4
View File
@@ -23,10 +23,8 @@ Objectif : un kit cohérent, fluide à animer, facile à imprimer (noir & blanc)
5. PR avec explication + previews PNG si possible
## Licence des contributions
- Contenu créatif : CC BY-NC 4.0
- Code : MIT
- Contenu creatif : CC BY-SA 4.0
- Code : GPL-3.0-or-later
- Contenu créatif : CC BY-SA 4.0 (voir `LICENSES/CC-BY-SA-4.0.txt`)
- Code : GPL-3.0-or-later (voir `LICENSES/GPL-3.0-or-later.txt`)
## Checklist PR (recommandée)
- [ ] Cohérence narrative validée (indices/cartes/solution).
+22
View File
@@ -0,0 +1,22 @@
PYTHON ?= python3
.PHONY: scenario-validate audio-validate printables-validate export all-validate images
scenario-validate:
$(PYTHON) tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml \
--audio-manifest audio/manifests/zacus_v1_audio.yaml
audio-validate:
$(PYTHON) tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml
printables-validate:
$(PYTHON) tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml
export:
$(PYTHON) tools/scenario/export_md.py game/scenarios/zacus_v1.yaml --output docs/scenario-zacus_v1.md
all-validate: scenario-validate audio-validate printables-validate
@echo "All validations passed."
images:
$(PYTHON) tools/images/generate_printables.py --manifest printables/manifests/zacus_v1_printables.yaml
+4 -11
View File
@@ -38,17 +38,10 @@ Dossier : `hardware/`
- **Printables** : structure `src/` + `export/{pdf,png}/` en place, prête à recevoir les livrables.
- **Hardware/firmware** : flux `U_LOCK`/`U-SON`/MP3 opérationnel et documenté (`hardware/firmware/esp32/`).
## Licence (non-commercial pour le contenu)
- Contenus créatifs (documents, PDFs, PNG, SVG, textes de jeu) : **CC BY-NC 4.0**
- Code (firmware, scripts) : **MIT**
- **Kit MJ** : structure documentaire en place (`kit-maitre-du-jeu/`).
- **Printables** : conventions définies, arborescence `src/export` à compléter selon les livrables.
- **Hardware/firmware** : flux `U_LOCK`/`U-SON`/MP3 opérationnel et documenté (`hardware/firmware/esp32/`).
## Licence (open source)
- Contenus créatifs (documents, PDFs, PNG, SVG, textes de jeu) : **CC BY-SA 4.0**
- Code (firmware, scripts) : **GPL-3.0-or-later**
Voir `LICENSE.md` et `LICENSES/`.
## Licence
- **Contenus créatifs (docs, PDFs, PNG, SVG, textes de jeu)** : CC BY-SA 4.0 (`LICENSES/CC-BY-SA-4.0.txt`).
- **Code (firmware et scripts)** : GPL-3.0-or-later (`LICENSES/GPL-3.0-or-later.txt`).
Consultez `LICENSE.md` pour les obligations générales et `LICENSES/` pour les textes complets.
## Disclaimer
Projet indépendant, non affilié à aucune marque ou éditeur.
+16
View File
@@ -0,0 +1,16 @@
packs:
- id: PACK_SONAR_HINT
title: "Indice sonar"
files:
- audio/pack-sonar-hint.mp3
duration_ms: 42000
- id: PACK_MORSE_HINT
title: "Indice morse"
files:
- audio/pack-morse-hint.mp3
duration_ms: 38000
- id: PACK_WIN
title: "Récompense victoire"
files:
- audio/pack-win.mp3
duration_ms: 54000
+7
View File
@@ -0,0 +1,7 @@
# Repo Review
- **Branch:** feature/MPRC-v3-full-radio-stack
- **Key folders:**
- `docs` (project documentation, guides, and generated content); `hardware` (firmware sources and board-specific assets, excluding esp32 for this review); `kit-maitre-du-jeu` (scenario content for the game master); `printables` (generated assets/exports); `tools` (generators and validators).
- `examples` (sample scenarios); `include-humain-IA` (AI helper assets); `LICENSES` (third-party notices alongside primary license files).
- **Red flags:** Ensure `LICENSE.md`, `LICENSES/*`, `CONTRIBUTING.md`, and `README.md` consistently describe attribution/licensing; normalized `include-humain-IA` avoids spaces/colons that can break tooling; keep generated folders (e.g., `printables/export`) gated.
- **Open PRs:** unable to fetch details because `gh pr list --state open --limit 50` failed (error connecting to api.github.com).
+41
View File
@@ -0,0 +1,41 @@
# Scenario ZACUS_V1
- Version: 1
- Initial step: STEP_LOCKED
## Steps
### STEP_LOCKED
- screen scene: SCENE_LOCKED
- audio pack: none
- apps: APP_LA, APP_SCREEN, APP_GATE
- transitions:
- on_event → unlock → STEP_SEARCH
### STEP_SEARCH
- screen scene: SCENE_SEARCH
- audio pack: PACK_SONAR_HINT
- apps: APP_AUDIO, APP_SCREEN, APP_GATE
- transitions:
- on_event → audio_done → STEP_MORSE
- on_event → serial → STEP_DONE
### STEP_MORSE
- screen scene: SCENE_CODE
- audio pack: PACK_MORSE_HINT
- apps: APP_AUDIO, APP_SCREEN, APP_GATE
- transitions:
- on_event → audio_done → STEP_WIN
- on_event → serial → STEP_DONE
### STEP_WIN
- screen scene: SCENE_REWARD
- audio pack: PACK_WIN
- apps: APP_AUDIO, APP_SCREEN, APP_GATE
- transitions:
- on_event → audio_done → STEP_DONE
- on_event → serial → STEP_DONE
### STEP_DONE
- screen scene: SCENE_DONE
- audio pack: none
- apps: APP_SCREEN, APP_GATE
+120
View File
@@ -0,0 +1,120 @@
id: ZACUS_V1
version: 1
initial_step: STEP_LOCKED
app_bindings:
- id: APP_LA
app: LA_DETECTOR
config:
hold_ms: 2500
unlock_event: UNLOCK
require_listening: true
- id: APP_AUDIO
app: AUDIO_PACK
- id: APP_SCREEN
app: SCREEN_SCENE
- id: APP_GATE
app: MP3_GATE
steps:
- step_id: STEP_LOCKED
screen_scene_id: SCENE_LOCKED
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
apps:
- APP_LA
- APP_SCREEN
- APP_GATE
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: STEP_SEARCH
after_ms: 0
priority: 100
- step_id: STEP_SEARCH
screen_scene_id: SCENE_SEARCH
audio_pack_id: PACK_SONAR_HINT
actions:
- ACTION_TRACE_STEP
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_MORSE
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: STEP_DONE
after_ms: 0
priority: 110
- step_id: STEP_MORSE
screen_scene_id: SCENE_CODE
audio_pack_id: PACK_MORSE_HINT
actions:
- ACTION_TRACE_STEP
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_WIN
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: STEP_DONE
after_ms: 0
priority: 110
- step_id: STEP_WIN
screen_scene_id: SCENE_REWARD
audio_pack_id: PACK_WIN
actions:
- ACTION_TRACE_STEP
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_DONE
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: STEP_DONE
after_ms: 0
priority: 110
- step_id: STEP_DONE
screen_scene_id: SCENE_DONE
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_REFRESH
apps:
- APP_SCREEN
- APP_GATE
mp3_gate_open: true
transitions: []
@@ -0,0 +1,16 @@
items:
- id: INVITATION
type: invitation
description: "Invitation labo"
prompt: src/prompts/invitation.txt
version: 1
- id: BADGE_ENQUETE
type: badge
description: "Badge détective en noir et blanc"
prompt: src/prompts/badge-detective.txt
version: 1
- id: REGLES_EXPRESS
type: rulesheet
description: "Règles express en une page"
prompt: src/prompts/regles-express.txt
version: 1
@@ -0,0 +1 @@
Badge détective : Police style, noir et blanc, inclure un titre "Détective en herbe", un symbole de loupe et une zone pour écrire le nom. Format carré A7.
+1
View File
@@ -0,0 +1 @@
Invitation officielle : les enfants sont invités à enquêter sur le mystérieux professeur Zacus. Indiquer la date, ladresse du labo et un ton mystérieux tout en restant clair pour un public 9-11 ans.
@@ -0,0 +1 @@
Règles express : résumer les objectifs, la durée, les consignes de polarité (ne pas tricher, garder le mystère) sur une page A4. Inclure bullets et icônes simples.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Basic validator for audio manifest entries."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError as exc:
print("Missing dependency: install PyYAML", exc)
sys.exit(1)
def main() -> int:
parser = argparse.ArgumentParser(description="Validate Zacus audio manifest")
parser.add_argument("manifest", type=Path)
args = parser.parse_args()
if not args.manifest.exists():
print(f"Manifest missing: {args.manifest}")
return 1
manifest = yaml.safe_load(args.manifest.read_text())
if not isinstance(manifest, dict):
print("Manifest must be a mapping")
return 1
packs = manifest.get("packs")
if not isinstance(packs, list) or not packs:
print("Manifest packs must be a non-empty list")
return 1
invalid = False
seen: set[str] = set()
for pack in packs:
if not isinstance(pack, dict):
print("Each pack entry must be a mapping")
invalid = True
continue
pack_id = pack.get("id")
if not pack_id or not isinstance(pack_id, str):
print("Pack missing id")
invalid = True
elif pack_id in seen:
print(f"Duplicate pack id {pack_id}")
invalid = True
else:
seen.add(pack_id)
files = pack.get("files")
if not isinstance(files, list) or not files:
print(f"pack {pack_id or 'unknown'} files must be a non-empty list")
invalid = True
else:
for item in files:
if not isinstance(item, str) or not item.strip():
print(f"pack {pack_id or 'unknown'} has invalid file entry")
invalid = True
duration = pack.get("duration_ms")
if duration is not None and (not isinstance(duration, int) or duration <= 0):
print(f"pack {pack_id or 'unknown'} duration must be a positive integer")
invalid = True
if invalid:
return 1
print(f"[audio-validate] {args.manifest} valid ({len(seen)} packs)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+16
View File
@@ -0,0 +1,16 @@
# Printable image generator
Creates PNG drafts for each printable asset with the OpenAI Images API.
## Requirements
- `OPENAI_API_KEY` in your environment.
- Dependencies installed in `.venv` (`PyYAML`, `openai`).
## Run
```sh
PYTHON=.venv/bin/python tools/images/generate_printables.py \
--manifest printables/manifests/zacus_v1_printables.yaml
```
Add `--force` to regenerate outputs even if files already exist.
Outputs land in `printables/export/png/zacus_v1/` and mirror the IDs from the manifest.
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Generate printable PNGs from the official printable manifest.
"""
from __future__ import annotations
import argparse
import base64
import os
import sys
from pathlib import Path
try:
import yaml
except ImportError as exc:
print("Missing dependency: install PyYAML:", exc)
sys.exit(1)
try:
import openai
except ImportError as exc:
print("Missing dependency: install openai", exc)
sys.exit(1)
from openai import OpenAI, OpenAIError
PRINTABLES_DIR = Path("printables")
MANIFEST_PATH = PRINTABLES_DIR / "manifests" / "zacus_v1_printables.yaml"
EXPORT_DIR = PRINTABLES_DIR / "export" / "png" / "zacus_v1"
MODEL = "gpt-image-1-mini"
SIZE = "1024x1024"
QUALITY = "low"
COST_PER_IMAGE = 0.005 # USD per low square image
def load_manifest(path: Path) -> list[dict]:
if not path.exists():
raise FileNotFoundError(f"Manifest not found: {path}")
manifest = yaml.safe_load(path.read_text())
items = manifest.get("items") or []
if not isinstance(items, list):
raise ValueError("Manifest items must be a list")
return items
def load_prompt(item: dict) -> tuple[str, Path]:
prompt_rel = item.get("prompt")
if not prompt_rel:
raise ValueError(f"Missing prompt reference for item {item.get('id')}")
prompt_path = PRINTABLES_DIR / prompt_rel
if not prompt_path.exists():
raise FileNotFoundError(f"Prompt file missing: {prompt_path}")
return prompt_path.read_text().strip(), prompt_path
def ensure_api_key() -> None:
if not os.environ.get("OPENAI_API_KEY"):
print("OPENAI_API_KEY is required for image generation")
sys.exit(1)
def generate_image(client: OpenAI, prompt: str) -> bytes:
response = client.images.generate(
model=MODEL,
prompt=prompt,
size=SIZE,
quality=QUALITY,
n=1,
output_format="png",
)
asset = response.data[0]
data = asset.b64_json
return base64.b64decode(data)
def main() -> int:
parser = argparse.ArgumentParser(description="Generate PNG printables via OpenAI Images")
parser.add_argument("--manifest", type=Path, default=MANIFEST_PATH, help="Printable manifest path")
parser.add_argument("--force", action="store_true", help="Regenerate even when PNG exists")
args = parser.parse_args()
ensure_api_key()
client = OpenAI()
items = load_manifest(args.manifest)
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
counters = {"generated": 0, "skipped": 0, "failed": 0}
for item in items:
item_id = item.get("id")
if not item_id:
print("Skipping entry without id")
counters["failed"] += 1
continue
destination = EXPORT_DIR / f"{item_id}.png"
if destination.exists() and not args.force:
counters["skipped"] += 1
continue
try:
prompt_text, prompt_path = load_prompt(item)
image_bytes = generate_image(client, prompt_text)
destination.write_bytes(image_bytes)
counters["generated"] += 1
print(f"Generated {destination}")
except (OpenAIError, FileNotFoundError, ValueError) as exc:
print(f"Failed {item_id}: {exc}")
counters["failed"] += 1
estimated_cost = counters["generated"] * COST_PER_IMAGE
print(
f"Summary: generated={counters['generated']}, skipped={counters['skipped']}, failed={counters['failed']}."
f" Estimated cost: ${estimated_cost:.3f}"
)
return 0 if counters["failed"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Validator for printable manifest files."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError as exc:
print("Missing dependency: install PyYAML", exc)
sys.exit(1)
def main() -> int:
parser = argparse.ArgumentParser(description="Validate printable manifest")
parser.add_argument("manifest", type=Path)
args = parser.parse_args()
if not args.manifest.exists():
print(f"Manifest missing: {args.manifest}")
return 1
manifest = yaml.safe_load(args.manifest.read_text())
if not isinstance(manifest, dict):
print("Manifest must be a mapping")
return 1
items = manifest.get("items")
if not isinstance(items, list) or not items:
print("Manifest items must be a non-empty list")
return 1
invalid = False
seen: set[str] = set()
for item in items:
if not isinstance(item, dict):
print("Each printable entry must be a mapping")
invalid = True
continue
item_id = item.get("id")
if not item_id or not isinstance(item_id, str):
print("Printable entry missing id")
invalid = True
elif item_id in seen:
print(f"Duplicate printable id {item_id}")
invalid = True
else:
seen.add(item_id)
if not item.get("type"):
print(f"Printable {item_id or 'unknown'} missing type")
invalid = True
prompt = item.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
print(f"Printable {item_id or 'unknown'} missing prompt path")
invalid = True
version = item.get("version")
if not isinstance(version, int) or version <= 0:
print(f"Printable {item_id or 'unknown'} version must be positive")
invalid = True
if invalid:
return 1
print(f"[printables-validate] {args.manifest} OK ({len(seen)} items)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Export a human-readable scenario summary."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
try:
import yaml
except ImportError as exc:
print("Missing dependency: install PyYAML", exc)
sys.exit(1)
def format_transitions(transitions: list[dict[str, str]]) -> list[str]:
lines: list[str] = []
for transition in transitions:
trigger = transition.get("trigger")
event = transition.get("event_type")
target = transition.get("target_step_id")
desc = f"{trigger}{event}{target}"
lines.append(desc)
return lines
def load_yaml(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(path)
data = yaml.safe_load(path.read_text())
if not isinstance(data, dict):
raise ValueError("scenario must be a mapping")
return data
def main() -> int:
parser = argparse.ArgumentParser(description="Render scenario summary to Markdown")
parser.add_argument("scenario", type=Path, help="scenario file path")
parser.add_argument("--output", type=Path, default=Path("docs/scenario-zacus_v1.md"))
args = parser.parse_args()
scenario = load_yaml(args.scenario)
scenario_id = scenario.get("id", "unknown")
version = scenario.get("version", "n/a")
initial = scenario.get("initial_step", "n/a")
steps = scenario.get("steps") or []
if not isinstance(steps, list):
raise ValueError("steps must be a list")
lines: list[str] = []
lines.append(f"# Scenario {scenario_id}")
lines.append("")
lines.append(f"- Version: {version}")
lines.append(f"- Initial step: {initial}")
lines.append("")
lines.append("## Steps")
for step in steps:
if not isinstance(step, dict):
continue
step_id = step.get("step_id", "unknown")
scene = step.get("screen_scene_id", "?")
audio_pack = step.get("audio_pack_id", "none")
apps = step.get("apps") or []
transitions = step.get("transitions") or []
app_list = ", ".join(str(app) for app in apps)
lines.append(f"### {step_id}")
lines.append(f"- screen scene: {scene}")
lines.append(f"- audio pack: {audio_pack or 'none'}")
lines.append(f"- apps: {app_list or 'none'}")
if transitions:
lines.append("- transitions:")
for line in format_transitions(transitions):
lines.append(f" - {line}")
lines.append("")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text("\n".join(lines).rstrip() + "\n")
print(f"[scenario-export] wrote {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Minimal validator for Zacus scenario YAML."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError as exc:
print("Missing dependency: install PyYAML", exc)
sys.exit(1)
ALLOWED_APPS = {"LA_DETECTOR", "AUDIO_PACK", "SCREEN_SCENE", "MP3_GATE"}
ALLOWED_TRIGGERS = {"on_event", "after_ms", "immediate"}
ALLOWED_EVENTS = {"unlock", "audio_done", "serial", "timer", "none"}
def load_yaml(path: Path) -> Any:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text())
def collect_pack_ids(manifest_path: Path) -> set[str]:
manifest = load_yaml(manifest_path)
packs = manifest.get("packs") or []
if not isinstance(packs, list):
raise ValueError("audio manifest packs must be a list")
ids: set[str] = set()
for entry in packs:
entry_id = entry.get("id")
if isinstance(entry_id, str):
ids.add(entry_id)
return ids
def report_issue(message: str) -> None:
print("[scenario-validate]", message)
def main() -> int:
parser = argparse.ArgumentParser(description="Validate scenario YAML for Zacus")
parser.add_argument("scenario", type=Path, help="path to scenario YAML")
parser.add_argument("--audio-manifest", type=Path, default=Path("audio/manifests/zacus_v1_audio.yaml"))
args = parser.parse_args()
try:
scenario = load_yaml(args.scenario)
except Exception as exc: # pragma: no cover
print(f"Failed to load scenario: {exc}")
return 1
issues = 0
if not isinstance(scenario, dict):
report_issue("top-level YAML must be a mapping")
return 1
scenario_id = scenario.get("id")
if not scenario_id or not isinstance(scenario_id, str):
report_issue("missing or invalid id")
issues += 1
version = scenario.get("version")
if not isinstance(version, int) or version <= 0:
report_issue("version must be a positive integer")
issues += 1
initial = scenario.get("initial_step")
if not isinstance(initial, str) or not initial:
report_issue("initial_step must be a non-empty string")
issues += 1
steps = scenario.get("steps")
if not isinstance(steps, list) or not steps:
report_issue("steps must be a non-empty list")
issues += 1
app_bindings = scenario.get("app_bindings") or []
if not isinstance(app_bindings, list) or not app_bindings:
report_issue("app_bindings must be a list of bindings")
issues += 1
bindings_by_id: dict[str, str] = {}
for binding in app_bindings:
if not isinstance(binding, dict):
continue
binding_id = binding.get("id")
binding_app = binding.get("app")
if not binding_id or not isinstance(binding_id, str):
report_issue("binding id must be a string")
issues += 1
continue
if not isinstance(binding_app, str) or binding_app not in ALLOWED_APPS:
report_issue(f"binding {binding_id} uses unsupported app {binding_app}")
issues += 1
bindings_by_id[binding_id] = binding_app
if initial and initial not in {step.get("step_id") for step in steps if isinstance(step, dict)}:
report_issue(f"initial_step {initial} not defined in steps")
issues += 1
pack_ids = set()
try:
pack_ids = collect_pack_ids(args.audio_manifest)
except Exception as exc:
report_issue(f"audio manifest error: {exc}")
issues += 1
seen_steps: set[str] = set()
for step in steps:
if not isinstance(step, dict):
report_issue("each step must be a mapping")
issues += 1
continue
step_id = step.get("step_id")
if not step_id or not isinstance(step_id, str):
report_issue("step missing step_id")
issues += 1
continue
if step_id in seen_steps:
report_issue(f"duplicate step_id {step_id}")
issues += 1
continue
seen_steps.add(step_id)
apps = step.get("apps") or []
if not isinstance(apps, list) or not apps:
report_issue(f"step {step_id} apps must be a non-empty list")
issues += 1
else:
for app in apps:
if app not in bindings_by_id:
report_issue(f"step {step_id} references unknown app {app}")
issues += 1
transitions = step.get("transitions") or []
if not isinstance(transitions, list):
report_issue(f"step {step_id} transitions must be a list")
issues += 1
continue
for transition in transitions:
if not isinstance(transition, dict):
report_issue(f"step {step_id} has invalid transition")
issues += 1
continue
trigger = transition.get("trigger")
if trigger not in ALLOWED_TRIGGERS:
report_issue(f"step {step_id} transition trigger {trigger} unsupported")
issues += 1
event_type = transition.get("event_type")
if event_type not in ALLOWED_EVENTS:
report_issue(f"step {step_id} transition event {event_type} unsupported")
issues += 1
target = transition.get("target_step_id")
if not target or not isinstance(target, str):
report_issue(f"step {step_id} transition missing target_step_id")
issues += 1
audio_pack = step.get("audio_pack_id")
if audio_pack and isinstance(audio_pack, str) and audio_pack not in pack_ids:
report_issue(f"step {step_id} references unknown audio_pack_id {audio_pack}")
issues += 1
if issues:
return 1
print(f"[scenario-validate] {scenario_id or 'scenario'} OK ({len(seen_steps)} steps)")
return 0
if __name__ == "__main__":
raise SystemExit(main())