chore: repo coherence pass (review, docs, validators, exports, manifests)
This commit is contained in:
+17
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Invitation officielle : les enfants sont invités à enquêter sur le mystérieux professeur Zacus. Indiquer la date, l’adresse 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.
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
Executable
+118
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user