chore: hard cutover frontend-scratch-v2

V2 was the legacy single-app Vite playground. Per the
2026-05-01 atelier design brainstorm, V3 is the sole
authoring target going forward. MediaManager and
NetworkPanel features explicitly retired (no porting).

Removed:
- frontend-scratch-v2/ entirely (50 files, ~11k LOC)

Retargeted to frontend-v3 + pnpm:
- Makefile: FRONTEND_DIR, frontend-typecheck/test/build
  (renamed from frontend-lint as eslint not yet wired in V3)
- tools/dev/zacus.sh: FRONTEND_ROOT, action commands
- tools/dev/zacus_tui.py: action_frontend_tests + build cwd
- .github/workflows/validate.yml: pnpm/action-setup,
  cache: pnpm, --frozen-lockfile, typecheck step added

Documentation cleanup:
- Root CLAUDE.md: Architecture, Where to Look, Nested
  Guidance lines purged of V2; pointer to atelier design
  doc added
- frontend-v3/CLAUDE.md: V2 vs V3 section removed; editor
  description points to fusion design doc
- desktop/CLAUDE.md: V2 anti-pattern removed
- specs/CLAUDE.md: cross-stack pair example updated to
  packages/scenario-engine

Archive: tag archive/frontend-scratch-v2-final on the
commit immediately preceding deletion preserves the last
state where V2 still worked.

Acceptance:
- rg "frontend-scratch-v2" in code paths -> 0 hits
- make frontend-test -> 11/11 green (3 skipped)
- make frontend-build -> 6/6 green
This commit is contained in:
L'électron rare
2026-05-02 21:51:23 +02:00
parent a0994efbb5
commit d7e4e52fd3
59 changed files with 133 additions and 11209 deletions
+14 -9
View File
@@ -23,15 +23,20 @@ jobs:
- name: Install docs dependencies
run: python -m pip install -r tools/requirements/docs.txt
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend-scratch-v2/package-lock.json
cache: pnpm
cache-dependency-path: frontend-v3/pnpm-lock.yaml
- name: Install frontend dependencies
run: npm --prefix frontend-scratch-v2 ci
run: pnpm --dir frontend-v3 install --frozen-lockfile
- name: Run canonical content checks
run: bash tools/test/run_content_checks.sh
@@ -39,14 +44,14 @@ jobs:
- name: Validate runtime bundle
run: python tools/scenario/validate_runtime_bundle.py
- name: Lint studio frontend
run: npm --prefix frontend-scratch-v2 run lint
- name: Typecheck frontend
run: pnpm --dir frontend-v3 typecheck
- name: Test studio frontend
run: npm --prefix frontend-scratch-v2 run test
- name: Test frontend
run: pnpm --dir frontend-v3 test
- name: Build studio frontend
run: npm --prefix frontend-scratch-v2 run build
- name: Build frontend
run: pnpm --dir frontend-v3 build
- name: Build docs
run: python -m mkdocs build --strict
+3 -4
View File
@@ -40,7 +40,7 @@ YAML scenario → compile_runtime3.py → Runtime 3 IR → ESP32 / Web player
Key surfaces:
- **Scenario IR**: `game/scenarios/zacus_v2.yaml``tools/scenario/compile_runtime3.py` → portable Runtime 3 IR. Contract: `specs/ZACUS_RUNTIME_3_SPEC.md`.
- **Authoring**: `frontend-scratch-v2/` (legacy single-app) and `frontend-v3/` (pnpm monorepo, dashboard + editor + simulation).
- **Authoring**: `frontend-v3/` (pnpm monorepo, dashboard + editor + simulation; editor + simulation will fuse into `apps/atelier/` per `docs/superpowers/specs/2026-05-01-v3-fusion-atelier-design.md`).
- **Firmware**: `ESP32_ZACUS/` submodule (separate repo, separate CI). Freenove ESP32-S3 + PlatformIO. NPC engine, voice pipeline, vision/QR, media manager.
- **Voice / NPC**: Piper TTS on Tower:8001 (zacus voice = tom-medium). NPC phrases in `game/scenarios/npc_phrases.yaml`. MP3 pool generator: `tools/tts/generate_npc_pool.py`.
- **MCP hardware**: `tools/dev/mcp_hardware_server.py` (stdio, 6 tools).
@@ -52,8 +52,7 @@ Key surfaces:
|------|----------|
| Edit scenarios, NPC phrases, prompts | `game/` |
| Modify Runtime 3 compiler / validators / TTS pool / dev CLI | `tools/` |
| Authoring UI (legacy) | `frontend-scratch-v2/` |
| Authoring UI / dashboard / simulation (current) | `frontend-v3/` |
| Authoring UI / dashboard / simulation | `frontend-v3/` |
| Zacus Studio macOS app | `desktop/` |
| Add or change a contract spec | `specs/` |
| Python tests (Runtime 3, NPC) | `tests/` |
@@ -87,4 +86,4 @@ User speaks French → respond in French. Code, comments, commits, docs → Engl
Domain-specific rules live in nested `CLAUDE.md` files and load automatically when you read files in those directories. Closest file wins. Current nested:
- `game/`, `tools/`, `tests/`, `specs/`
- `frontend-scratch-v2/`, `frontend-v3/`, `desktop/`
- `frontend-v3/`, `desktop/`
+93
View File
@@ -0,0 +1,93 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
**Le Mystère du Professeur Zacus** — hybrid educational escape room game with ESP32-S3 hardware, React authoring studio, and a portable Runtime 3 scenario engine. Scenarios are authored in YAML, compiled to a portable IR, and executed on both web and ESP32 targets.
## Build & Test
```bash
# Full validation pipeline
make all-validate
# Content checks (schema validation for scenarios, audio, printables)
make content-checks
# Runtime 3 — compile, simulate, verify, test
make runtime3-compile # YAML → Runtime 3 IR
make runtime3-simulate # simulate scenario execution
make runtime3-verify # verify pivot logic
make runtime3-test # Python unittest suite
# Frontend (React 19 + Blockly + Vite)
cd frontend-scratch-v2
npm test # Vitest (18 tests)
npm run build # tsc -b + vite build
npx vitest run tests/specific.test.ts # single test
# Compile a specific scenario
python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml
# Dev CLI (12 actions)
./tools/dev/zacus.sh content-checks
./tools/dev/zacus.sh runtime3-compile
./tools/dev/zacus.sh voice-bridge start|stop|status|test
```
## Architecture
### Data Flow
```
YAML scenario → compile_runtime3.py → Runtime 3 IR → ESP32 / Web player
Voice bridge → Piper TTS (Tower:8001)
Hints engine → /hints/ask (3 puzzles, 3 levels)
```
### Key Components
- **Scenario engine**: `game/scenarios/zacus_v2.yaml` is the canonical source. Runtime 3 spec in `specs/ZACUS_RUNTIME_3_SPEC.md`.
- **Frontend studio** (`frontend-scratch-v2/`): React 19 + Blockly 12.4 + Monaco Editor + Zod validation. Vite bundler, TypeScript.
- **ESP32 firmware** (`ESP32_ZACUS/` submodule): Freenove ESP32-S3, PlatformIO build. Separate repo with its own CI. Contains voice pipeline scaffold, audio/vision/QR detection, media manager.
- **Runtime 3 compiler** (`tools/scenario/compile_runtime3.py`): YAML → portable IR with pivots, zones, triggers.
- **Voice pipeline**: Piper TTS on Tower:8001 (3 voices: zacus=tom-medium, siwis, upmc), ESP-SR for wake word. Voice bridge routes `[HINT:puzzle:level]` to hints engine.
- **MCP hardware server** (`tools/dev/mcp_hardware_server.py`): 6 tools, stdio transport for hardware interaction.
- **TUI dashboard** (`tools/dev/zacus_tui.py`): 12 actions, logs, CI mode.
- **Analytics**: ESP32 module + 6 web endpoints + Dashboard UI.
- **NPC Engine** (`ESP32_ZACUS/ui_freenove_allinone/include/npc/npc_engine.h` + `src/npc/npc_engine.cpp`): Lightweight C state machine for Professor Zacus NPC. Trigger rules (stuck timer, QR scan, fast/slow progress, hint request), mood system (neutral/impressed/worried/amused), hybrid audio routing (live Piper TTS when Tower reachable, SD card MP3 fallback). NPC phrase bank in `game/scenarios/npc_phrases.yaml`.
- **TTS Client** (`ESP32_ZACUS/ui_freenove_allinone/include/npc/tts_client.h` + `src/npc/tts_client.cpp`): HTTP client for Piper TTS on Tower:8001 with health-check, PSRAM WAV buffer, and SD card fallback. Voice: tom-medium.
- **NPC Phrase Bank** (`game/scenarios/npc_phrases.yaml`): All Professor Zacus lines in French, organized by category: hints (3 levels × 6 scenes), congratulations, warnings, personality comments (by mood), adaptation phrases (skip/challenge/timer), narrative bridges, false leads, and ambiance (intro/outro/idle).
- **NPC MP3 Pool Generator** (`tools/tts/generate_npc_pool.py`): Python tool that reads `npc_phrases.yaml`, calls Piper TTS API for each phrase, writes MP3 files to `hotline_tts/`, and generates `hotline_tts/manifest.json`. Idempotent (skips already-generated files). Run: `python3 tools/tts/generate_npc_pool.py [--dry-run]`.
### AI Integration
- 6 AI agent definitions in `.github/agents/` (voice, tts, vision, hints, audio_gen, mcp)
- Hints engine: anti-cheat, 3 difficulty levels, per-puzzle context
- Spec: `specs/AI_INTEGRATION_SPEC.md`
## Canonical Files
| File | Role |
|------|------|
| `game/scenarios/zacus_v2.yaml` | Scenario source of truth (v3, Runtime 3) |
| `game/scenarios/npc_phrases.yaml` | Professor Zacus NPC phrase bank (all categories, French) |
| `tools/tts/generate_npc_pool.py` | NPC MP3 pool generator (Piper TTS → hotline_tts/) |
| `specs/ZACUS_RUNTIME_3_SPEC.md` | Runtime contract definition |
| `specs/AI_INTEGRATION_SPEC.md` | AI layer architecture |
| `Makefile` | Main automation entry point |
| `docs/QUICKSTART.md` | Getting started |
| `docs/DEPLOYMENT_RUNBOOK.md` | Field deployment |
| `docs/debt/codex-firmware-fixes-to-apply.md` | Pending firmware fixes (I2S, CORS, AP password) |
## Language & Communication
- User speaks **French**, code and docs in **English**
- Respond in French for conversation, English for code/comments/commits
## Infrastructure
- **Tower** (`clems@192.168.0.120`): Piper TTS FR on port 8001
- **KXKM-AI** (`kxkm@kxkm-ai`): RTX 4090, GPU inference
- SSH is key-based only, never use sshpass
+6 -6
View File
@@ -1,8 +1,8 @@
PYTHON ?= python3
SCENARIO ?= game/scenarios/zacus_v2.yaml
FRONTEND_DIR ?= frontend-scratch-v2
FRONTEND_DIR ?= frontend-v3
.PHONY: bootstrap-validators bootstrap-docs scenario-validate audio-validate printables-validate export validate-runtime-bundle content-checks runtime3-compile runtime3-simulate runtime3-verify runtime3-test runtime3-firmware-bundle frontend-lint frontend-test frontend-build docs-build docs-serve all-validate images
.PHONY: bootstrap-validators bootstrap-docs scenario-validate audio-validate printables-validate export validate-runtime-bundle content-checks runtime3-compile runtime3-simulate runtime3-verify runtime3-test runtime3-firmware-bundle frontend-typecheck frontend-test frontend-build docs-build docs-serve all-validate images
bootstrap-validators:
bash tools/setup/install_validators.sh
@@ -43,14 +43,14 @@ runtime3-test:
runtime3-firmware-bundle:
$(PYTHON) tools/scenario/export_runtime3_firmware_bundle.py $(SCENARIO)
frontend-lint:
cd $(FRONTEND_DIR) && npm run lint
frontend-typecheck:
cd $(FRONTEND_DIR) && pnpm typecheck
frontend-test:
cd $(FRONTEND_DIR) && npm run test
cd $(FRONTEND_DIR) && pnpm test
frontend-build:
cd $(FRONTEND_DIR) && npm run build
cd $(FRONTEND_DIR) && pnpm build
docs-build:
$(PYTHON) -m mkdocs build --strict
-1
View File
@@ -42,4 +42,3 @@ npm run rebuild-native # zacus-native addon for current Electron ABI
- Calling `require('serialport')` from renderer (security: nodeIntegration must stay false)
- Hardcoding device paths (`/dev/cu.usbserial-*`) — enumerate at runtime via `serialport.list()`
- Skipping notarization on release builds (Gatekeeper will block)
- Bundling V2 frontend instead of V3 — V2 is dev-only
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-47
View File
@@ -1,47 +0,0 @@
# Frontend Scratch V2
Authoring studio: React 19 + Blockly 12 + Monaco Editor + Zod + Vite 7 + Vitest 3. TypeScript strict.
## Layout
```
src/
App.tsx, main.tsx # Entry
components/ # UI (scenario editor, blocks, panels)
lib/
api.ts # ESP32/runtime HTTP client
runtime3.ts # Runtime 3 IR types + compiler bindings (TS mirror of Python)
scenario.ts # Scenario YAML/JSON helpers
useRuntimeStore.ts # Zustand-style runtime state hook
__tests__/ # Co-located unit tests
tests/
scenario-runtime3.test.ts # Integration test against real fixtures
```
## Commands
```bash
npm run dev # Vite dev server
npm run build # tsc -b && vite build
npm test # Vitest run (18 tests)
npm run lint # ESLint flat config
```
## Patterns
- `lib/runtime3.ts` is the TS mirror of Python `tools/scenario/runtime3_common.py` — keep field names + optionality identical. When the Python IR changes, update both in the same PR.
- Validate scenario imports through Zod schemas, not raw `JSON.parse` — Zod errors surface bad fixtures during dev.
- Blockly toolbox/blocks live in `components/scenario-editor/` — register custom blocks before `Blockly.inject`.
- Monaco editor language is YAML; load worker via Vite import, never CDN.
## Tests
- Vitest config inherits from `vite.config.ts`. Use `vitest run --reporter verbose` for diff output.
- Cross-stack invariant tests (Python compile → TS replay) live in `tests/scenario-runtime3.test.ts`. Run them locally before claiming Runtime 3 work is done.
## Anti-Patterns
- Drifting `lib/runtime3.ts` types from the Python compiler — schedule a sync, don't bandaid
- Calling `fetch()` outside `lib/api.ts` — keep network code centralised
- Using `any` to escape Zod typing — narrow with `z.infer` instead
- Importing from `frontend-v3/` packages — V2 and V3 share no runtime code
-76
View File
@@ -1,76 +0,0 @@
# Zacus Story Designer — React 19 + Blockly
Studio auteur visuel pour concevoir des scenarios Zacus avec des blocs type Scratch.
Genere du YAML canonique et communique avec le firmware ESP32 via l'API Story V2.
## Setup
```bash
cd frontend-scratch-v2
npm install
npm run dev
```
Connexion API (optionnel) :
```bash
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
```
## Scripts disponibles
| Commande | Description |
| --- | --- |
| `npm run dev` | Serveur de dev Vite (HMR) |
| `npm run build` | Build production (`dist/`) |
| `npm test` | Tests Vitest (18 tests) |
| `npm run lint` | ESLint |
## Architecture
L'interface s'organise en 4 onglets :
| Onglet | Composant | Role |
| --- | --- | --- |
| Designer | `BlocklyDesigner.tsx` | Editeur blocs + generation YAML live |
| Dashboard | `Dashboard.tsx` | Vue d'ensemble scenario |
| Media | `MediaManager.tsx` | Gestion assets audio/image |
| Network | `NetworkPanel.tsx` | Monitoring devices terrain |
Autres fichiers cles :
- `src/components/RuntimeControls.tsx` — actions HTTP Story V2 (list, status, validate, deploy)
- `src/lib/scenario.ts` — mapping blocs → document scenario → YAML
- `src/types.ts` — types TypeScript du document scenario
## Stack OSS
| Brique | Version | Licence | Role |
| --- | --- | --- | --- |
| blockly | 12.4.1 | Apache-2.0 | editeur blocs |
| yaml | 2.8.2 | ISC | serialisation YAML |
| zod | 4.3.6 | MIT | validation locale |
| ajv | 8.18.0 | MIT | JSON schema |
| @monaco-editor/react | 4.7.0 | MIT | vue YAML |
Note : `scratch-gui` / `scratch-vm` sont AGPL-3.0, non retenus pour garder un front permissif.
## API Story V2
| Methode | Endpoint | Description |
| --- | --- | --- |
| GET | `/api/story/list` | Liste des scenarios |
| GET | `/api/story/status` | Statut runtime |
| POST | `/api/story/validate` | Validation YAML |
| POST | `/api/story/deploy` | Deploiement sur device |
Variable d'environnement : `VITE_STORY_API_BASE` (defaut : pas de proxy).
## Tests
18 tests passing (Vitest). Lancer avec `npm test`.
## Prochaines etapes
1. Mapping complet steps + transitions (FSM) avec validation croisee
2. Import/export Blockly JSON
3. Tests E2E avec API mock
-23
View File
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-scratch-v2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
{
"name": "frontend-scratch-v2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"ajv": "^8.18.0",
"blockly": "^12.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^3.2.4"
}
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-511
View File
@@ -1,511 +0,0 @@
/* ─── Reset & Base ─── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--bg-card: #1a1d27;
--bg-input: #252833;
--border: #2e3142;
--text: #e4e6eb;
--text-muted: #8b8fa3;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #22c55e;
--red: #ef4444;
--orange: #f59e0b;
--radius: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* ─── Shell ─── */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ─── Header ─── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
gap: 16px;
flex-wrap: wrap;
}
.app-header h1 {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
}
.header-status {
display: flex;
align-items: center;
gap: 10px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.connected {
background: var(--green);
box-shadow: 0 0 6px var(--green);
}
.dot.disconnected {
background: var(--red);
}
.api-url-input {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 4px 10px;
font-size: 13px;
font-family: var(--mono);
width: 240px;
}
.header-step {
color: var(--accent);
font-size: 13px;
}
/* ─── Tabs ─── */
.tab-bar {
display: flex;
gap: 0;
background: var(--bg-card);
border-bottom: 2px solid var(--border);
padding: 0 20px;
}
.tab {
padding: 10px 20px;
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
border-radius: 0;
}
.tab:hover {
color: var(--text);
background: none;
border-color: transparent;
border-bottom-color: var(--text-muted);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ─── Main ─── */
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* ─── Cards ─── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
.card h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.panel-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.editor-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* ─── KV pairs ─── */
.kv {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.kv:last-child {
border-bottom: none;
}
.runtime3-grid {
display: grid;
gap: 8px 16px;
}
.runtime3-span {
grid-column: 1 / -1;
}
.runtime3-path {
word-break: break-all;
text-align: right;
}
/* ─── Badges ─── */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background: var(--bg-input);
color: var(--text-muted);
}
.badge.ok {
background: rgba(34, 197, 94, 0.15);
color: var(--green);
}
.badge.err {
background: rgba(239, 68, 68, 0.15);
color: var(--red);
}
.badge.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
}
/* ─── Buttons ─── */
button {
padding: 6px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
button:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
/* ─── Inputs ─── */
input[type='text'],
input[type='number'] {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
/* ─── Notices ─── */
.notice {
padding: 8px 12px;
border-radius: var(--radius);
font-size: 13px;
margin: 8px 0;
}
.notice.warn {
background: rgba(245, 158, 11, 0.12);
color: var(--orange);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.notice.info {
background: rgba(59, 130, 246, 0.12);
color: var(--accent);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.notice.err {
background: rgba(239, 68, 68, 0.12);
color: var(--red);
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* ─── Progress ─── */
.progress-bar {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.progress-bar .progress-fill {
height: 6px;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s;
flex: 1;
max-width: 200px;
}
.progress-bar span {
font-size: 12px;
font-family: var(--mono);
}
/* ─── Lists ─── */
.scenario-list,
.file-list {
list-style: none;
}
.scenario-list li,
.file-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.scenario-list li:last-child,
.file-list li:last-child {
border-bottom: none;
}
/* ─── Misc ─── */
.mono {
font-family: var(--mono);
font-size: 13px;
}
.muted {
color: var(--text-muted);
font-size: 13px;
}
.feedback {
margin-top: 8px;
padding: 6px 10px;
border-radius: var(--radius);
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
font-size: 13px;
}
.error-banner {
background: rgba(239, 68, 68, 0.12);
color: var(--red);
padding: 8px 12px;
border-radius: var(--radius);
margin-top: 12px;
}
.event-json {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
background: var(--bg);
padding: 8px;
border-radius: var(--radius);
overflow-x: auto;
max-height: 200px;
}
/* ─── Designer grid ─── */
.app-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 900px) {
.app-grid {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.panel h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
/* ─── Blockly ─── */
.blockly-host {
height: 50vh;
min-height: 340px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
align-items: center;
}
.status-line {
margin-top: 8px;
padding: 6px 10px;
border-radius: var(--radius);
background: rgba(34, 197, 94, 0.1);
color: var(--green);
font-size: 13px;
}
.status-line.error {
background: rgba(239, 68, 68, 0.1);
color: var(--red);
}
/* ─── Runtime panel ─── */
.runtime-panel {
margin-top: 12px;
}
.runtime-panel pre {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
background: var(--bg);
padding: 8px;
border-radius: var(--radius);
overflow-x: auto;
max-height: 200px;
margin-top: 8px;
white-space: pre-wrap;
}
/* ─── Rec form ─── */
.rec-form {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.rec-form input {
width: 120px;
}
/* ─── Dashboard layout ─── */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.dashboard h2 {
grid-column: 1 / -1;
font-size: 18px;
font-weight: 700;
}
.media-manager,
.network-panel {
max-width: 720px;
}
/* ─── Scenario Editor ─── */
.scenario-editor {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.scenario-editor-toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
align-items: center;
}
.scenario-editor-toolbar select {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
}
.scenario-editor-status {
color: var(--text-muted);
font-size: 13px;
margin-left: auto;
}
.scenario-editor-split {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 12px;
flex: 1;
min-height: 0;
}
.scenario-editor-blockly {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
min-height: 400px;
}
.scenario-editor-preview {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
min-height: 400px;
}
@media (max-width: 900px) {
.scenario-editor-split {
grid-template-columns: 1fr;
}
}
-216
View File
@@ -1,216 +0,0 @@
import { Component, Suspense, lazy, useState } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { RuntimeControls } from './components/RuntimeControls';
import { Dashboard } from './components/Dashboard';
import { useRuntimeStore } from './lib/useRuntimeStore';
import { setApiBase, getApiBase } from './lib/api';
import './App.css';
// ─── Lazy-loaded heavy components ───
const LazyBlocklyDesigner = lazy(() =>
import('./components/BlocklyDesigner').then((m) => ({ default: m.BlocklyDesigner })),
);
const LazyMonacoEditor = lazy(() => import('@monaco-editor/react'));
const LazyMediaManager = lazy(() =>
import('./components/MediaManager').then((m) => ({ default: m.MediaManager })),
);
const LazyNetworkPanel = lazy(() =>
import('./components/NetworkPanel').then((m) => ({ default: m.NetworkPanel })),
);
const LazyScenarioEditor = lazy(() =>
import('./components/ScenarioEditor').then((m) => ({ default: m.ScenarioEditor })),
);
const LazyFallback = (
<div style={{ padding: '2rem', textAlign: 'center' }}>Loading editor...</div>
);
// ─── ErrorBoundary ───
interface ErrorBoundaryProps {
children: ReactNode;
fallbackLabel?: string;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(`[ErrorBoundary${this.props.fallbackLabel ? ` ${this.props.fallbackLabel}` : ''}]`, error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary-fallback" role="alert">
<h2>Something went wrong{this.props.fallbackLabel ? ` in ${this.props.fallbackLabel}` : ''}.</h2>
<pre>{this.state.error?.message}</pre>
<button type="button" onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
export { ErrorBoundary };
type Tab = 'dashboard' | 'designer' | 'scenario' | 'media' | 'network';
const TABS: { id: Tab; label: string }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'designer', label: 'Designer' },
{ id: 'scenario', label: 'Scenario' },
{ id: 'media', label: 'Media' },
{ id: 'network', label: 'Network' },
];
function App() {
const [tab, setTab] = useState<Tab>('dashboard');
const [yaml, setYaml] = useState('');
const [runtime3Json, setRuntime3Json] = useState('');
const [apiUrl, setApiUrl] = useState(getApiBase());
const runtime = useRuntimeStore();
const handleApiChange = (url: string) => {
setApiUrl(url);
setApiBase(url);
};
return (
<div className="app-shell">
<header className="app-header">
<h1>Zacus Studio V2</h1>
<div className="header-status">
<span
className={`dot ${runtime.connected ? 'connected' : 'disconnected'}`}
/>
<input
type="text"
className="api-url-input"
value={apiUrl}
onChange={(e) => handleApiChange(e.target.value.trim())}
aria-label="ESP32 API URL"
placeholder="http://esp32-ip:8080"
/>
{runtime.story && (
<span className="header-step mono">
{runtime.story.current_step}
</span>
)}
</div>
</header>
<nav className="tab-bar">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={`tab ${tab === t.id ? 'active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</nav>
<main className="main-content">
{tab === 'dashboard' && (
<ErrorBoundary fallbackLabel="Dashboard">
<Dashboard runtime={runtime} refresh={runtime.refresh} />
</ErrorBoundary>
)}
{tab === 'designer' && (
<ErrorBoundary fallbackLabel="Designer">
<Suspense fallback={LazyFallback}>
<div className="app-grid">
<section className="panel">
<h2>Designer blocs</h2>
<LazyBlocklyDesigner
onDraftChange={(draft) => {
setYaml(draft.yaml);
setRuntime3Json(draft.runtime3Json);
}}
/>
</section>
<section className="panel panel-stack">
<div className="editor-group">
<h2>YAML canonique</h2>
<LazyMonacoEditor
height="28vh"
defaultLanguage="yaml"
value={yaml}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
readOnly: true,
}}
/>
</div>
<div className="editor-group">
<h2>IR Runtime 3</h2>
<LazyMonacoEditor
height="28vh"
defaultLanguage="json"
value={runtime3Json}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
readOnly: true,
}}
/>
</div>
<RuntimeControls yaml={yaml} />
</section>
</div>
</Suspense>
</ErrorBoundary>
)}
{tab === 'scenario' && (
<ErrorBoundary fallbackLabel="Scenario">
<Suspense fallback={LazyFallback}>
<LazyScenarioEditor />
</Suspense>
</ErrorBoundary>
)}
{tab === 'media' && (
<ErrorBoundary fallbackLabel="Media">
<Suspense fallback={LazyFallback}>
<LazyMediaManager runtime={runtime} />
</Suspense>
</ErrorBoundary>
)}
{tab === 'network' && (
<ErrorBoundary fallbackLabel="Network">
<Suspense fallback={LazyFallback}>
<LazyNetworkPanel runtime={runtime} />
</Suspense>
</ErrorBoundary>
)}
</main>
</div>
);
}
export default App;
@@ -1,137 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// We need to test the internal jsonFetch + the exported helpers.
// Since jsonFetch is not exported, we test it indirectly through storyList / storyStatus
// and also test setApiBase / getApiBase directly.
import {
setApiBase,
getApiBase,
storyList,
storyStatus,
ApiError,
} from '../lib/api';
// ─── Mock globalThis.fetch ───
const mockFetch = vi.fn<(...args: unknown[]) => Promise<Response>>();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
setApiBase('http://test-esp:8080');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ─── Helpers ───
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
// ─── Tests ───
describe('setApiBase / getApiBase', () => {
it('stores and returns the base URL', () => {
setApiBase('http://192.168.0.42:8080');
expect(getApiBase()).toBe('http://192.168.0.42:8080');
});
it('strips trailing slashes', () => {
setApiBase('http://host:9000///');
expect(getApiBase()).toBe('http://host:9000');
});
});
describe('jsonFetch (via storyList)', () => {
it('returns parsed JSON on 200', async () => {
const payload = { scenarios: [{ id: 'demo', version: 1, estimated_duration_s: 300 }] };
mockFetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await storyList();
expect(result).toEqual(payload.scenarios);
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toBe('http://test-esp:8080/api/story/list');
});
it('throws ApiError on HTTP 500', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: 'internal failure' }, 500),
);
try {
await storyList();
expect.unreachable('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(500);
expect((err as ApiError).message).toBe('internal failure');
}
});
it('throws on timeout (AbortError path)', async () => {
// Simulate a request that never resolves — the AbortController will fire
mockFetch.mockImplementation(
(...args: unknown[]) =>
new Promise((_resolve, reject) => {
const init = args[1] as { signal?: AbortSignal } | undefined;
if (init?.signal) {
init.signal.addEventListener('abort', () => {
const err = new DOMException('The operation was aborted.', 'AbortError');
reject(err);
});
}
}),
);
// storyStatus uses the default 5000ms timeout — too long for tests.
// Instead we test indirectly: the AbortController fires, the promise rejects.
// We reduce the wait by calling storyStatus which has default timeout.
// For speed we rely on the abort signal being set up correctly.
const promise = storyStatus();
await expect(promise).rejects.toThrow(/timed out/);
}, 10_000);
});
describe('storyList', () => {
it('calls /api/story/list and unwraps scenarios', async () => {
const scenarios = [
{ id: 'a', version: 2, estimated_duration_s: 600 },
{ id: 'b', version: 1, estimated_duration_s: 120 },
];
mockFetch.mockResolvedValueOnce(jsonResponse({ scenarios }));
const result = await storyList();
expect(result).toHaveLength(2);
expect(result[0].id).toBe('a');
});
});
describe('storyStatus', () => {
it('calls /api/story/status and returns status object', async () => {
const status = {
status: 'running',
scenario_id: 'demo',
current_step: 'STEP_1',
progress_pct: 42,
started_at_ms: 1700000000000,
selected: 'demo',
queue_depth: 0,
};
mockFetch.mockResolvedValueOnce(jsonResponse(status));
const result = await storyStatus();
expect(result.status).toBe('running');
expect(result.current_step).toBe('STEP_1');
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toBe('http://test-esp:8080/api/story/status');
});
});
@@ -1,168 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
compileScenarioDocumentToRuntime3,
validateRuntime3Document,
} from '../lib/runtime3';
import type { ScenarioDocument } from '../types';
// ─── Helpers ───
function makeDocument(overrides?: Partial<ScenarioDocument>): ScenarioDocument {
return {
id: 'test-scenario',
version: 1,
title: 'Test Scenario',
players: { min: 2, max: 6 },
duration: { total_minutes: 45 },
canon: { introduction: 'Intro', stakes: 'Stakes' },
stations: [],
puzzles: [],
steps_narrative: [{ step_id: 'intro', scene: 'scene1', narrative: 'Start' }],
firmware: {
initial_step: 'intro',
steps: [
{
step_id: 'intro',
screen_scene_id: 'scene_welcome',
audio_pack_id: 'pack_intro',
actions: ['play_music'],
apps: [],
transitions: [
{
event_type: 'button',
event_name: 'btn_next',
target_step_id: 'puzzle_1',
priority: 10,
after_ms: 0,
},
{
event_type: 'timer',
event_name: 'timeout',
target_step_id: 'puzzle_1',
priority: 5,
after_ms: 30000,
},
],
},
{
step_id: 'puzzle_1',
screen_scene_id: 'scene_puzzle',
audio_pack_id: 'pack_puzzle',
actions: [],
apps: ['puzzle_app'],
transitions: [
{
event_type: 'serial',
event_name: 'solved',
target_step_id: 'intro',
priority: 0,
after_ms: 0,
},
],
},
],
},
...overrides,
};
}
// ─── Tests ───
describe('compileScenarioDocumentToRuntime3', () => {
it('produces correct schema_version', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
expect(result.schema_version).toBe('zacus.runtime3.v1');
});
it('sets metadata correctly', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
expect(result.metadata.generated_by).toBe('frontend-scratch-v2');
expect(result.metadata.migration_mode).toBe('native');
});
it('normalizes step IDs to uppercase', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
for (const step of result.steps) {
expect(step.id).toBe(step.id.toUpperCase());
}
expect(result.steps[0].id).toBe('INTRO');
expect(result.steps[1].id).toBe('PUZZLE_1');
});
it('normalizes transition target_step_id to uppercase', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
for (const step of result.steps) {
for (const t of step.transitions) {
expect(t.target_step_id).toBe(t.target_step_id.toUpperCase());
}
}
});
it('sorts transitions are preserved with priority values', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
const introTransitions = result.steps[0].transitions;
expect(introTransitions).toHaveLength(2);
// Priorities are preserved as-is from the source
expect(introTransitions[0].priority).toBe(10);
expect(introTransitions[1].priority).toBe(5);
});
it('sets entry_step_id correctly from firmware.initial_step', () => {
const result = compileScenarioDocumentToRuntime3(makeDocument());
expect(result.scenario.entry_step_id).toBe('INTRO');
});
it('falls back to first firmware step if initial_step is missing', () => {
const doc = makeDocument();
delete doc.firmware!.initial_step;
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.scenario.entry_step_id).toBe('INTRO');
});
it('falls back to STEP_BOOT when no steps exist', () => {
const doc = makeDocument({
firmware: { steps: [] },
steps_narrative: [],
});
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.scenario.entry_step_id).toBe('STEP_BOOT');
});
it('normalizes scenario id', () => {
const doc = makeDocument({ id: 'my cool scenario!' });
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.scenario.id).toBe('MY_COOL_SCENARIO');
});
it('handles unknown event_type by falling back to serial', () => {
const doc = makeDocument();
doc.firmware!.steps![0].transitions[0].event_type = 'unknown_event';
const result = compileScenarioDocumentToRuntime3(doc);
expect(result.steps[0].transitions[0].event_type).toBe('serial');
});
});
describe('validateRuntime3Document', () => {
it('validates a correct document', () => {
const doc = compileScenarioDocumentToRuntime3(makeDocument());
const result = validateRuntime3Document(doc);
expect(result).toEqual({ ok: true });
});
it('rejects empty steps', () => {
const doc = compileScenarioDocumentToRuntime3(
makeDocument({ firmware: { steps: [] }, steps_narrative: [] }),
);
doc.steps = [];
const result = validateRuntime3Document(doc);
expect(result).toEqual({ ok: false, error: 'runtime3 requires at least one step' });
});
it('rejects duplicate step IDs', () => {
const doc = compileScenarioDocumentToRuntime3(makeDocument());
doc.steps.push({ ...doc.steps[0] }); // duplicate
const result = validateRuntime3Document(doc);
expect(result.ok).toBe(false);
expect('error' in result && result.error).toContain('duplicate');
});
});
@@ -1,81 +0,0 @@
import { describe, it, expect } from 'vitest';
import { decodeScenarioFromUrl } from '../../components/ScenarioEditor/export/share';
// ─── Share URL encode/decode tests ───
// We test the core encode/decode logic without requiring window.location.
// encodeScenarioToUrl() uses window.location at runtime, so we test the
// underlying btoa/atob round-trip directly via decodeScenarioFromUrl.
function encodeForTest(xml: string): string {
const compressed = btoa(encodeURIComponent(xml));
return `#scenario=${compressed}`;
}
describe('share URL encode/decode round-trip', () => {
it('round-trips a simple XML string', () => {
const xml = '<xml><block type="scenario_scene"></block></xml>';
const hash = encodeForTest(xml);
expect(hash).toContain('#scenario=');
const decoded = decodeScenarioFromUrl(hash);
expect(decoded).toBe(xml);
});
it('round-trips XML with special characters (French, entities)', () => {
const xml = '<xml><block name="scène éàü &amp; test"></block></xml>';
const hash = encodeForTest(xml);
const decoded = decodeScenarioFromUrl(hash);
expect(decoded).toBe(xml);
});
it('round-trips an empty workspace', () => {
const xml = '<xml></xml>';
const hash = encodeForTest(xml);
const decoded = decodeScenarioFromUrl(hash);
expect(decoded).toBe(xml);
});
it('returns null for invalid hash formats', () => {
expect(decodeScenarioFromUrl('')).toBeNull();
expect(decodeScenarioFromUrl('#other=abc')).toBeNull();
expect(decodeScenarioFromUrl('#noscenario')).toBeNull();
expect(decodeScenarioFromUrl('no-hash-at-all')).toBeNull();
});
it('returns null for corrupted base64 payload', () => {
expect(decodeScenarioFromUrl('#scenario=!!!invalid-base64!!!')).toBeNull();
});
});
// ─── Download utility tests ───
// downloadYaml and downloadJson require document/URL which are browser-only.
// We verify the module exports exist and the function signatures are correct.
// Actual DOM interaction is covered by integration/E2E tests.
describe('download module exports', () => {
it('exports downloadYaml function', async () => {
const mod = await import('../../components/ScenarioEditor/export/download');
expect(typeof mod.downloadYaml).toBe('function');
expect(mod.downloadYaml.length).toBe(2); // (yaml, filename)
});
it('exports downloadJson function', async () => {
const mod = await import('../../components/ScenarioEditor/export/download');
expect(typeof mod.downloadJson).toBe('function');
expect(mod.downloadJson.length).toBe(2); // (data, filename)
});
});
describe('share module exports', () => {
it('exports encodeScenarioToUrl function', async () => {
const mod = await import('../../components/ScenarioEditor/export/share');
expect(typeof mod.encodeScenarioToUrl).toBe('function');
expect(mod.encodeScenarioToUrl.length).toBe(1); // (workspaceXml)
});
it('exports decodeScenarioFromUrl function', async () => {
const mod = await import('../../components/ScenarioEditor/export/share');
expect(typeof mod.decodeScenarioFromUrl).toBe('function');
expect(mod.decodeScenarioFromUrl.length).toBe(1); // (hash)
});
});
@@ -1,262 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as Blockly from 'blockly';
import { ensureScenarioBlocks } from '../../components/ScenarioEditor/blocks/scene';
import { registerPuzzleBlocks } from '../../components/ScenarioEditor/blocks/puzzle';
import { registerNpcBlocks } from '../../components/ScenarioEditor/blocks/npc';
import { registerHardwareBlocks } from '../../components/ScenarioEditor/blocks/hardware';
import { registerDeployBlocks } from '../../components/ScenarioEditor/blocks/deploy';
import {
buildScenarioGraph,
scenarioGraphToFirmwareYaml,
scenarioGraphToDisplayYaml,
} from '../../components/ScenarioEditor/generators/yaml';
import YAML from 'yaml';
beforeAll(() => {
ensureScenarioBlocks();
registerPuzzleBlocks();
registerNpcBlocks();
registerHardwareBlocks();
registerDeployBlocks();
});
function createWorkspace(): Blockly.Workspace {
return new Blockly.Workspace();
}
// ─── Hardware blocks ───
describe('hw_gpio_write block', () => {
it('creates a valid HardwareAction for gpio_write', () => {
const ws = createWorkspace();
const block = ws.newBlock('hw_gpio_write');
block.setFieldValue('12', 'PIN');
block.setFieldValue('LOW', 'STATE');
const graph = buildScenarioGraph(ws);
const gpioActions = graph.hardwareActions.filter((a) => a.type === 'gpio_write');
expect(gpioActions).toHaveLength(1);
expect(gpioActions[0].pin).toBe(12);
expect(gpioActions[0].state).toBe('LOW');
ws.dispose();
});
});
describe('hw_gpio_read block', () => {
it('creates a valid HardwareAction for gpio_read', () => {
const ws = createWorkspace();
const block = ws.newBlock('hw_gpio_read');
block.setFieldValue('7', 'PIN');
block.setFieldValue('sensor_val', 'VARIABLE');
const graph = buildScenarioGraph(ws);
const readActions = graph.hardwareActions.filter((a) => a.type === 'gpio_read');
expect(readActions).toHaveLength(1);
expect(readActions[0].pin).toBe(7);
expect(readActions[0].variable).toBe('sensor_val');
ws.dispose();
});
});
describe('hw_led_set block', () => {
it('creates HardwareAction with color and animation', () => {
const ws = createWorkspace();
const block = ws.newBlock('hw_led_set');
block.setFieldValue('#FF0000', 'COLOR');
block.setFieldValue('blink', 'ANIMATION');
const graph = buildScenarioGraph(ws);
const ledActions = graph.hardwareActions.filter((a) => a.type === 'led_set');
expect(ledActions).toHaveLength(1);
expect(ledActions[0].color).toBe('#FF0000');
expect(ledActions[0].animation).toBe('blink');
ws.dispose();
});
});
describe('hw_buzzer_tone block', () => {
it('creates HardwareAction with frequency and duration', () => {
const ws = createWorkspace();
const block = ws.newBlock('hw_buzzer_tone');
block.setFieldValue('880', 'FREQUENCY');
block.setFieldValue('1000', 'DURATION_MS');
const graph = buildScenarioGraph(ws);
const buzzerActions = graph.hardwareActions.filter((a) => a.type === 'buzzer');
expect(buzzerActions).toHaveLength(1);
expect(buzzerActions[0].frequency).toBe(880);
expect(buzzerActions[0].duration_ms).toBe(1000);
ws.dispose();
});
});
describe('hw_play_audio block', () => {
it('creates HardwareAction with filename', () => {
const ws = createWorkspace();
const block = ws.newBlock('hw_play_audio');
block.setFieldValue('intro.mp3', 'FILENAME');
const graph = buildScenarioGraph(ws);
const audioActions = graph.hardwareActions.filter((a) => a.type === 'play_audio');
expect(audioActions).toHaveLength(1);
expect(audioActions[0].filename).toBe('intro.mp3');
ws.dispose();
});
});
describe('hw_qr_scan block', () => {
it('creates HardwareAction for qr_scan with no extra fields', () => {
const ws = createWorkspace();
ws.newBlock('hw_qr_scan');
const graph = buildScenarioGraph(ws);
const qrActions = graph.hardwareActions.filter((a) => a.type === 'qr_scan');
expect(qrActions).toHaveLength(1);
ws.dispose();
});
});
// ─── Deploy blocks ───
describe('deploy_config_wifi block', () => {
it('produces DeployConfig with wifi settings', () => {
const ws = createWorkspace();
const block = ws.newBlock('deploy_config_wifi');
block.setFieldValue('ZacusNet', 'SSID');
block.setFieldValue('s3cr3t', 'PASSWORD');
const graph = buildScenarioGraph(ws);
expect(graph.deploy.wifi).toBeDefined();
expect(graph.deploy.wifi!.ssid).toBe('ZacusNet');
expect(graph.deploy.wifi!.password).toBe('s3cr3t');
ws.dispose();
});
});
describe('deploy_config_tts block', () => {
it('produces DeployConfig with TTS settings', () => {
const ws = createWorkspace();
const block = ws.newBlock('deploy_config_tts');
block.setFieldValue('http://tower:8001', 'URL');
block.setFieldValue('siwis', 'VOICE');
const graph = buildScenarioGraph(ws);
expect(graph.deploy.tts).toBeDefined();
expect(graph.deploy.tts!.url).toBe('http://tower:8001');
expect(graph.deploy.tts!.voice).toBe('siwis');
ws.dispose();
});
});
describe('deploy_config_llm block', () => {
it('produces DeployConfig with LLM settings', () => {
const ws = createWorkspace();
const block = ws.newBlock('deploy_config_llm');
block.setFieldValue('http://localhost:11434', 'URL');
block.setFieldValue('qwen2.5', 'MODEL');
const graph = buildScenarioGraph(ws);
expect(graph.deploy.llm).toBeDefined();
expect(graph.deploy.llm!.url).toBe('http://localhost:11434');
expect(graph.deploy.llm!.model).toBe('qwen2.5');
ws.dispose();
});
});
describe('deploy_export block', () => {
it('can be created without errors', () => {
const ws = createWorkspace();
const block = ws.newBlock('deploy_export');
expect(block.type).toBe('deploy_export');
ws.dispose();
});
});
// ─── Full scenario integration ───
describe('full scenario with hardware + deploy generates complete YAML', () => {
it('produces display YAML with hardware and deploy sections', () => {
const ws = createWorkspace();
// Scene
const scene = ws.newBlock('scenario_scene');
scene.setFieldValue('SCENE_LAB', 'NAME');
scene.setFieldValue('Laboratory', 'DESCRIPTION');
scene.setFieldValue('180', 'DURATION_MAX');
// Hardware: LED + buzzer
const led = ws.newBlock('hw_led_set');
led.setFieldValue('#0000FF', 'COLOR');
led.setFieldValue('pulse', 'ANIMATION');
const buzzer = ws.newBlock('hw_buzzer_tone');
buzzer.setFieldValue('660', 'FREQUENCY');
buzzer.setFieldValue('250', 'DURATION_MS');
// Deploy: WiFi + TTS
const wifi = ws.newBlock('deploy_config_wifi');
wifi.setFieldValue('EscapeRoom', 'SSID');
wifi.setFieldValue('pass123', 'PASSWORD');
const tts = ws.newBlock('deploy_config_tts');
tts.setFieldValue('http://192.168.0.120:8001', 'URL');
tts.setFieldValue('tom-medium', 'VOICE');
const graph = buildScenarioGraph(ws);
expect(graph.scenes).toHaveLength(1);
expect(graph.hardwareActions).toHaveLength(2);
expect(graph.deploy.wifi).toBeDefined();
expect(graph.deploy.tts).toBeDefined();
// Display YAML
const displayYaml = scenarioGraphToDisplayYaml(graph);
const parsed = YAML.parse(displayYaml);
expect(parsed.scenes).toHaveLength(1);
expect(parsed.scenes[0].name).toBe('SCENE_LAB');
expect(parsed.hardware).toHaveLength(2);
expect(parsed.hardware[0].type).toBe('led_set');
expect(parsed.hardware[0].color).toBe('#0000FF');
expect(parsed.hardware[1].type).toBe('buzzer');
expect(parsed.deploy).toBeDefined();
expect(parsed.deploy.wifi.ssid).toBe('EscapeRoom');
expect(parsed.deploy.tts.voice).toBe('tom-medium');
// Firmware YAML
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const fwParsed = YAML.parse(firmwareYaml);
expect(fwParsed.firmware.steps).toHaveLength(1);
expect(fwParsed.firmware.steps[0].actions).toHaveLength(2);
expect(fwParsed.firmware.steps[0].actions[0].type).toBe('led_set');
expect(fwParsed.firmware.deploy).toBeDefined();
expect(fwParsed.firmware.deploy.wifi.ssid).toBe('EscapeRoom');
ws.dispose();
});
it('produces clean YAML when no hardware or deploy blocks are present', () => {
const ws = createWorkspace();
const scene = ws.newBlock('scenario_scene');
scene.setFieldValue('SCENE_EMPTY', 'NAME');
const graph = buildScenarioGraph(ws);
expect(graph.hardwareActions).toHaveLength(0);
expect(graph.deploy).toEqual({});
const displayYaml = scenarioGraphToDisplayYaml(graph);
const parsed = YAML.parse(displayYaml);
expect(parsed.hardware).toBeUndefined();
expect(parsed.deploy).toBeUndefined();
ws.dispose();
});
});
@@ -1,266 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as Blockly from 'blockly';
import { ensureScenarioBlocks } from '../../components/ScenarioEditor/blocks/scene';
import { registerPuzzleBlocks } from '../../components/ScenarioEditor/blocks/puzzle';
import { registerNpcBlocks } from '../../components/ScenarioEditor/blocks/npc';
import {
buildScenarioGraph,
scenarioGraphToFirmwareYaml,
scenarioGraphToDisplayYaml,
} from '../../components/ScenarioEditor/generators/yaml';
import YAML from 'yaml';
beforeAll(() => {
ensureScenarioBlocks();
registerPuzzleBlocks();
registerNpcBlocks();
});
function createWorkspace(): Blockly.Workspace {
return new Blockly.Workspace();
}
describe('puzzle_definition block', () => {
it('creates a valid PuzzleNode from a puzzle block', () => {
const ws = createWorkspace();
const block = ws.newBlock('puzzle_definition');
block.setFieldValue('PUZZLE_QR_1', 'NAME');
block.setFieldValue('qr', 'PUZZLE_TYPE');
const graph = buildScenarioGraph(ws);
expect(graph.puzzles).toHaveLength(1);
expect(graph.puzzles[0].name).toBe('PUZZLE_QR_1');
expect(graph.puzzles[0].type).toBe('qr');
expect(graph.puzzles[0].hints).toEqual([]);
ws.dispose();
});
it('reads hints nested inside puzzle_definition', () => {
const ws = createWorkspace();
const puzzle = ws.newBlock('puzzle_definition');
puzzle.setFieldValue('PUZZLE_DOOR', 'NAME');
puzzle.setFieldValue('button', 'PUZZLE_TYPE');
const hint1 = ws.newBlock('npc_hint');
hint1.setFieldValue('1', 'LEVEL');
hint1.setFieldValue('Look at the door', 'TEXT');
hint1.setFieldValue('PUZZLE_DOOR', 'PUZZLE_ID');
const hint2 = ws.newBlock('npc_hint');
hint2.setFieldValue('2', 'LEVEL');
hint2.setFieldValue('Try the red button', 'TEXT');
hint2.setFieldValue('PUZZLE_DOOR', 'PUZZLE_ID');
// Connect hints to puzzle HINTS input
const hintsInput = puzzle.getInput('HINTS');
if (hintsInput?.connection && hint1.previousConnection) {
hintsInput.connection.connect(hint1.previousConnection);
}
if (hint1.nextConnection && hint2.previousConnection) {
hint1.nextConnection.connect(hint2.previousConnection);
}
const graph = buildScenarioGraph(ws);
expect(graph.puzzles).toHaveLength(1);
expect(graph.puzzles[0].hints).toHaveLength(2);
expect(graph.puzzles[0].hints[0].level).toBe(1);
expect(graph.puzzles[0].hints[0].text).toBe('Look at the door');
expect(graph.puzzles[0].hints[1].level).toBe(2);
ws.dispose();
});
});
describe('puzzle_validation_qr block', () => {
it('produces correct solution field when connected to puzzle', () => {
const ws = createWorkspace();
const puzzle = ws.newBlock('puzzle_definition');
puzzle.setFieldValue('QR_PUZZLE', 'NAME');
puzzle.setFieldValue('qr', 'PUZZLE_TYPE');
const qrVal = ws.newBlock('puzzle_validation_qr');
qrVal.setFieldValue('ZACUS_KEY_1', 'EXPECTED');
// Connect QR validation to SOLUTION input
const solutionInput = puzzle.getInput('SOLUTION');
if (solutionInput?.connection && qrVal.outputConnection) {
solutionInput.connection.connect(qrVal.outputConnection);
}
const graph = buildScenarioGraph(ws);
expect(graph.puzzles[0].solution).toBe('qr:ZACUS_KEY_1');
ws.dispose();
});
});
describe('puzzle_validation_button block', () => {
it('produces correct solution field for button validation', () => {
const ws = createWorkspace();
const puzzle = ws.newBlock('puzzle_definition');
puzzle.setFieldValue('BTN_PUZZLE', 'NAME');
puzzle.setFieldValue('button', 'PUZZLE_TYPE');
const btnVal = ws.newBlock('puzzle_validation_button');
btnVal.setFieldValue('7', 'PIN');
const solutionInput = puzzle.getInput('SOLUTION');
if (solutionInput?.connection && btnVal.outputConnection) {
solutionInput.connection.connect(btnVal.outputConnection);
}
const graph = buildScenarioGraph(ws);
expect(graph.puzzles[0].solution).toBe('button:7');
ws.dispose();
});
});
describe('puzzle_condition block', () => {
it('creates a condition with type and reference', () => {
const ws = createWorkspace();
const cond = ws.newBlock('puzzle_condition');
cond.setFieldValue('puzzle_solved', 'CONDITION_TYPE');
cond.setFieldValue('PUZZLE_QR_1', 'REFERENCE');
// puzzle_condition is a value block (output), verify it exists
expect(cond.outputConnection).toBeTruthy();
ws.dispose();
});
});
describe('npc_say block', () => {
it('creates NPCAction with text and mood', () => {
const ws = createWorkspace();
const say = ws.newBlock('npc_say');
say.setFieldValue('Welcome, adventurer!', 'TEXT');
say.setFieldValue('amused', 'MOOD');
const graph = buildScenarioGraph(ws);
const sayActions = graph.npcActions.filter((a) => a.type === 'say');
expect(sayActions).toHaveLength(1);
expect(sayActions[0].text).toBe('Welcome, adventurer!');
expect(sayActions[0].mood).toBe('amused');
ws.dispose();
});
});
describe('npc_hint block (standalone)', () => {
it('links hint to puzzle ID', () => {
const ws = createWorkspace();
const hint = ws.newBlock('npc_hint');
hint.setFieldValue('2', 'LEVEL');
hint.setFieldValue('Check under the table', 'TEXT');
hint.setFieldValue('PUZZLE_DOOR', 'PUZZLE_ID');
const graph = buildScenarioGraph(ws);
const hintActions = graph.npcActions.filter((a) => a.type === 'hint');
expect(hintActions).toHaveLength(1);
expect(hintActions[0].level).toBe(2);
expect(hintActions[0].text).toBe('Check under the table');
expect(hintActions[0].puzzleId).toBe('PUZZLE_DOOR');
ws.dispose();
});
});
describe('npc_react block', () => {
it('creates a react action with condition', () => {
const ws = createWorkspace();
const react = ws.newBlock('npc_react');
react.setFieldValue('Well done!', 'RESPONSE');
const cond = ws.newBlock('puzzle_condition');
cond.setFieldValue('puzzle_solved', 'CONDITION_TYPE');
cond.setFieldValue('QR_1', 'REFERENCE');
const condInput = react.getInput('CONDITION');
if (condInput?.connection && cond.outputConnection) {
condInput.connection.connect(cond.outputConnection);
}
const graph = buildScenarioGraph(ws);
const reactActions = graph.npcActions.filter((a) => a.type === 'react');
expect(reactActions).toHaveLength(1);
expect(reactActions[0].text).toBe('Well done!');
expect(reactActions[0].condition).toBe('puzzle_condition');
ws.dispose();
});
});
describe('npc_conversation block', () => {
it('creates a conversation action with system prompt', () => {
const ws = createWorkspace();
const conv = ws.newBlock('npc_conversation');
conv.setFieldValue('You are Professor Zacus', 'SYSTEM_PROMPT');
conv.setFieldValue('escape room intro', 'CONTEXT');
const graph = buildScenarioGraph(ws);
const convActions = graph.npcActions.filter((a) => a.type === 'conversation');
expect(convActions).toHaveLength(1);
expect(convActions[0].systemPrompt).toBe('You are Professor Zacus');
expect(convActions[0].text).toBe('escape room intro');
ws.dispose();
});
});
describe('full scenario with scenes + puzzles + NPC generates valid YAML', () => {
it('produces display YAML with scenes, puzzles, and npc sections', () => {
const ws = createWorkspace();
// Scene
const scene = ws.newBlock('scenario_scene');
scene.setFieldValue('SCENE_INTRO', 'NAME');
scene.setFieldValue('Welcome', 'DESCRIPTION');
scene.setFieldValue('120', 'DURATION_MAX');
// Puzzle
const puzzle = ws.newBlock('puzzle_definition');
puzzle.setFieldValue('PUZZLE_QR', 'NAME');
puzzle.setFieldValue('qr', 'PUZZLE_TYPE');
const qrVal = ws.newBlock('puzzle_validation_qr');
qrVal.setFieldValue('KEY_42', 'EXPECTED');
const solInput = puzzle.getInput('SOLUTION');
if (solInput?.connection && qrVal.outputConnection) {
solInput.connection.connect(qrVal.outputConnection);
}
// NPC say (standalone, not inside scene actions)
const say = ws.newBlock('npc_say');
say.setFieldValue('Bienvenue!', 'TEXT');
say.setFieldValue('impressed', 'MOOD');
const graph = buildScenarioGraph(ws);
expect(graph.scenes).toHaveLength(1);
expect(graph.puzzles).toHaveLength(1);
expect(graph.npcActions.length).toBeGreaterThanOrEqual(1);
// Display YAML
const displayYaml = scenarioGraphToDisplayYaml(graph);
const parsed = YAML.parse(displayYaml);
expect(parsed.scenes).toHaveLength(1);
expect(parsed.scenes[0].name).toBe('SCENE_INTRO');
expect(parsed.puzzles).toHaveLength(1);
expect(parsed.puzzles[0].name).toBe('PUZZLE_QR');
expect(parsed.puzzles[0].type).toBe('qr');
expect(parsed.puzzles[0].solution).toBe('qr:KEY_42');
expect(parsed.npc).toBeDefined();
expect(parsed.npc.length).toBeGreaterThanOrEqual(1);
// Firmware YAML
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const fwParsed = YAML.parse(firmwareYaml);
expect(fwParsed.firmware.steps).toHaveLength(1);
expect(fwParsed.firmware.puzzles).toHaveLength(1);
expect(fwParsed.firmware.puzzles[0].puzzle_id).toBe('PUZZLE_QR');
expect(fwParsed.firmware.puzzles[0].type).toBe('qr');
ws.dispose();
});
});
@@ -1,165 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as Blockly from 'blockly';
import { ensureScenarioBlocks } from '../../components/ScenarioEditor/blocks/scene';
import {
buildScenarioGraph,
scenarioGraphToFirmwareYaml,
scenarioGraphToDisplayYaml,
} from '../../components/ScenarioEditor/generators/yaml';
import YAML from 'yaml';
beforeAll(() => {
ensureScenarioBlocks();
});
function createWorkspace(): Blockly.Workspace {
return new Blockly.Workspace();
}
describe('scenario_scene block', () => {
it('creates a valid graph node from a single scene block', () => {
const ws = createWorkspace();
const block = ws.newBlock('scenario_scene');
block.setFieldValue('SCENE_INTRO', 'NAME');
block.setFieldValue('Welcome to the game', 'DESCRIPTION');
block.setFieldValue('120', 'DURATION_MAX');
const graph = buildScenarioGraph(ws);
expect(graph.scenes).toHaveLength(1);
expect(graph.scenes[0].name).toBe('SCENE_INTRO');
expect(graph.scenes[0].description).toBe('Welcome to the game');
expect(graph.scenes[0].durationMax).toBe(120);
expect(graph.scenes[0].transitions).toEqual([]);
expect(graph.scenes[0].actions).toEqual([]);
ws.dispose();
});
});
describe('scenario_transition block', () => {
it('links two scenes via a transition', () => {
const ws = createWorkspace();
// Create scene with a transition in TRANSITIONS input
const scene = ws.newBlock('scenario_scene');
scene.setFieldValue('SCENE_A', 'NAME');
const transition = ws.newBlock('scenario_transition');
transition.setFieldValue('SCENE_B', 'TARGET_SCENE');
// Connect transition to scene's TRANSITIONS input
const input = scene.getInput('TRANSITIONS');
if (input?.connection && transition.previousConnection) {
input.connection.connect(transition.previousConnection);
}
const graph = buildScenarioGraph(ws);
expect(graph.scenes).toHaveLength(1);
expect(graph.scenes[0].transitions).toHaveLength(1);
expect(graph.scenes[0].transitions[0].targetScene).toBe('SCENE_B');
ws.dispose();
});
});
describe('scenario_timer block', () => {
it('produces correct firmware transition from timer action', () => {
const ws = createWorkspace();
const scene = ws.newBlock('scenario_scene');
scene.setFieldValue('SCENE_TIMED', 'NAME');
const timer = ws.newBlock('scenario_timer');
timer.setFieldValue('30', 'SECONDS');
// Add a variable_set inside timer's ON_EXPIRE
const varSet = ws.newBlock('scenario_variable_set');
varSet.setFieldValue('game_over', 'NAME');
const timerOnExpire = timer.getInput('ON_EXPIRE');
if (timerOnExpire?.connection && varSet.previousConnection) {
timerOnExpire.connection.connect(varSet.previousConnection);
}
// Connect timer to scene's ACTIONS input
const actionsInput = scene.getInput('ACTIONS');
if (actionsInput?.connection && timer.previousConnection) {
actionsInput.connection.connect(timer.previousConnection);
}
const graph = buildScenarioGraph(ws);
expect(graph.scenes[0].actions).toHaveLength(1);
expect(graph.scenes[0].actions[0].kind).toBe('timer');
const timerAction = graph.scenes[0].actions[0];
if (timerAction.kind === 'timer') {
expect(timerAction.seconds).toBe(30);
expect(timerAction.onExpire).toHaveLength(1);
expect(timerAction.onExpire[0].kind).toBe('variable_set');
}
// Check firmware YAML output
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const parsed = YAML.parse(firmwareYaml);
const step = parsed.firmware.steps[0];
expect(step.step_id).toBe('SCENE_TIMED');
// Timer with variable_set on_expire should produce an action-type transition
const actionTransition = step.transitions.find(
(t: { event_type: string }) => t.event_type === 'action',
);
expect(actionTransition).toBeDefined();
expect(actionTransition.after_ms).toBe(30000);
ws.dispose();
});
});
describe('full graph to firmware YAML round-trip', () => {
it('generates valid firmware YAML from a multi-scene graph', () => {
const ws = createWorkspace();
// Scene A
const sceneA = ws.newBlock('scenario_scene');
sceneA.setFieldValue('SCENE_A', 'NAME');
sceneA.setFieldValue('First scene', 'DESCRIPTION');
sceneA.setFieldValue('60', 'DURATION_MAX');
// Transition A -> B
const transAB = ws.newBlock('scenario_transition');
transAB.setFieldValue('SCENE_B', 'TARGET_SCENE');
const inputA = sceneA.getInput('TRANSITIONS');
if (inputA?.connection && transAB.previousConnection) {
inputA.connection.connect(transAB.previousConnection);
}
// Scene B
const sceneB = ws.newBlock('scenario_scene');
sceneB.setFieldValue('SCENE_B', 'NAME');
sceneB.setFieldValue('Second scene', 'DESCRIPTION');
const graph = buildScenarioGraph(ws);
expect(graph.scenes).toHaveLength(2);
// Firmware YAML
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const fwParsed = YAML.parse(firmwareYaml);
expect(fwParsed.firmware.initial_step).toBe('SCENE_A');
expect(fwParsed.firmware.steps).toHaveLength(2);
expect(fwParsed.firmware.steps[0].step_id).toBe('SCENE_A');
expect(fwParsed.firmware.steps[0].transitions[0].target_step_id).toBe('SCENE_B');
expect(fwParsed.firmware.steps[1].step_id).toBe('SCENE_B');
// Display YAML
const displayYaml = scenarioGraphToDisplayYaml(graph);
const displayParsed = YAML.parse(displayYaml);
expect(displayParsed.scenes).toHaveLength(2);
expect(displayParsed.scenes[0].name).toBe('SCENE_A');
expect(displayParsed.scenes[0].description).toBe('First scene');
expect(displayParsed.scenes[0].duration_max_s).toBe(60);
expect(displayParsed.scenes[0].transitions[0].target).toBe('SCENE_B');
expect(displayParsed.scenes[1].name).toBe('SCENE_B');
ws.dispose();
});
});
@@ -1,153 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
validateScenarioGraph,
formatValidationSummary,
} from '../../components/ScenarioEditor/validators/workspace';
import type { ScenarioGraph } from '../../components/ScenarioEditor/types';
function makeGraph(partial: Partial<ScenarioGraph>): ScenarioGraph {
return {
scenes: [],
puzzles: [],
npcActions: [],
hardwareActions: [],
deploy: {},
...partial,
};
}
describe('validateScenarioGraph', () => {
it('returns error for empty scenario', () => {
const graph = makeGraph({});
const issues = validateScenarioGraph(graph);
expect(issues.some((i) => i.message === 'No scenes defined')).toBe(true);
});
it('detects duplicate scene IDs', () => {
const graph = makeGraph({
scenes: [
{ name: 'SCENE_1', description: '', durationMax: 300, actions: [], transitions: [] },
{ name: 'SCENE_1', description: '', durationMax: 300, actions: [], transitions: [] },
],
});
const issues = validateScenarioGraph(graph);
expect(issues.some((i) => i.message.includes('Duplicate scene ID'))).toBe(true);
});
it('detects dangling transition target', () => {
const graph = makeGraph({
scenes: [
{
name: 'SCENE_1',
description: '',
durationMax: 300,
actions: [],
transitions: [{ targetScene: 'NONEXISTENT', condition: '' }],
},
],
});
const issues = validateScenarioGraph(graph);
expect(issues.some((i) => i.message.includes('not found'))).toBe(true);
});
it('warns about unreachable scenes', () => {
const graph = makeGraph({
scenes: [
{ name: 'SCENE_1', description: '', durationMax: 300, actions: [], transitions: [] },
{ name: 'SCENE_2', description: '', durationMax: 300, actions: [], transitions: [] },
],
});
const issues = validateScenarioGraph(graph);
expect(issues.some((i) => i.message.includes('unreachable'))).toBe(true);
});
it('warns about puzzles without hints', () => {
const graph = makeGraph({
scenes: [
{ name: 'SCENE_1', description: '', durationMax: 300, actions: [], transitions: [] },
],
puzzles: [{ id: 'p1', name: 'Test', type: 'qr', hints: [] }],
});
const issues = validateScenarioGraph(graph);
expect(issues.some((i) => i.message.includes('no hints'))).toBe(true);
});
it('detects duplicate puzzle IDs', () => {
const graph = makeGraph({
scenes: [
{ name: 'SCENE_1', description: '', durationMax: 300, actions: [], transitions: [] },
],
puzzles: [
{ id: 'p1', name: 'A', type: 'qr', hints: [{ level: 1, text: 'h' }] },
{ id: 'p1', name: 'B', type: 'button', hints: [{ level: 1, text: 'h' }] },
],
});
const issues = validateScenarioGraph(graph);
expect(issues.some((i) => i.message.includes('Duplicate puzzle ID'))).toBe(true);
});
it('returns no errors for valid scenario', () => {
const graph = makeGraph({
scenes: [
{
name: 'SCENE_INTRO',
description: 'Intro',
durationMax: 300,
actions: [],
transitions: [{ targetScene: 'SCENE_END', condition: '' }],
},
{
name: 'SCENE_END',
description: 'End',
durationMax: 300,
actions: [],
transitions: [],
},
],
puzzles: [{ id: 'p1', name: 'Test', type: 'qr', hints: [{ level: 1, text: 'hint' }] }],
npcActions: [{ type: 'say', text: 'Hello' }],
});
const issues = validateScenarioGraph(graph);
expect(issues.filter((i) => i.severity === 'error')).toHaveLength(0);
});
});
describe('formatValidationSummary', () => {
it('shows Ready for valid graph', () => {
const graph = makeGraph({
scenes: [
{ name: 'S', description: '', durationMax: 300, actions: [], transitions: [] },
],
});
const summary = formatValidationSummary(graph, []);
expect(summary).toContain('Ready');
expect(summary).toContain('1 scenes');
});
it('shows error count when errors exist', () => {
const graph = makeGraph({
scenes: [
{ name: 'S', description: '', durationMax: 300, actions: [], transitions: [] },
],
});
const issues = [
{ severity: 'error' as const, message: 'bad' },
{ severity: 'warning' as const, message: 'meh' },
];
const summary = formatValidationSummary(graph, issues);
expect(summary).toContain('1 error');
expect(summary).toContain('1 warning');
});
it('shows warning count when only warnings', () => {
const graph = makeGraph({
scenes: [
{ name: 'S', description: '', durationMax: 300, actions: [], transitions: [] },
],
});
const issues = [{ severity: 'warning' as const, message: 'meh' }];
const summary = formatValidationSummary(graph, issues);
expect(summary).toContain('1 warning');
expect(summary).not.toContain('error');
});
});
@@ -1,453 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as Blockly from 'blockly';
import { ensureScenarioBlocks } from '../../components/ScenarioEditor/blocks/scene';
import { registerPuzzleBlocks } from '../../components/ScenarioEditor/blocks/puzzle';
import { registerNpcBlocks } from '../../components/ScenarioEditor/blocks/npc';
import { registerHardwareBlocks } from '../../components/ScenarioEditor/blocks/hardware';
import { registerDeployBlocks } from '../../components/ScenarioEditor/blocks/deploy';
import {
buildScenarioGraph,
scenarioGraphToFirmwareYaml,
scenarioGraphToDisplayYaml,
} from '../../components/ScenarioEditor/generators/yaml';
import YAML from 'yaml';
beforeAll(() => {
ensureScenarioBlocks();
registerPuzzleBlocks();
registerNpcBlocks();
registerHardwareBlocks();
registerDeployBlocks();
});
function createWorkspace(): Blockly.Workspace {
return new Blockly.Workspace();
}
// ─── Helper: connect block to a statement input ───
function connectStatement(
parent: Blockly.Block,
inputName: string,
child: Blockly.Block,
): void {
const input = parent.getInput(inputName);
if (input?.connection && child.previousConnection) {
input.connection.connect(child.previousConnection);
}
}
function connectValue(
parent: Blockly.Block,
inputName: string,
child: Blockly.Block,
): void {
const input = parent.getInput(inputName);
if (input?.connection && child.outputConnection) {
input.connection.connect(child.outputConnection);
}
}
function chainBlocks(first: Blockly.Block, second: Blockly.Block): void {
if (first.nextConnection && second.previousConnection) {
first.nextConnection.connect(second.previousConnection);
}
}
// ─── Scenario 1: Simple linear ───
describe('YAML snapshot: simple linear scenario', () => {
it('generates display YAML with 2 scenes, 1 transition, 1 NPC say', () => {
const ws = createWorkspace();
// Scene 1: INTRO with transition to FINALE
const scene1 = ws.newBlock('scenario_scene');
scene1.setFieldValue('SCENE_INTRO', 'NAME');
scene1.setFieldValue('Welcome to the escape room', 'DESCRIPTION');
scene1.setFieldValue('120', 'DURATION_MAX');
const trans = ws.newBlock('scenario_transition');
trans.setFieldValue('SCENE_FINALE', 'TARGET_SCENE');
connectStatement(scene1, 'TRANSITIONS', trans);
// Scene 2: FINALE
const scene2 = ws.newBlock('scenario_scene');
scene2.setFieldValue('SCENE_FINALE', 'NAME');
scene2.setFieldValue('Congratulations!', 'DESCRIPTION');
scene2.setFieldValue('60', 'DURATION_MAX');
// NPC say (standalone top-level block)
const npcSay = ws.newBlock('npc_say');
npcSay.setFieldValue('Bienvenue dans le laboratoire!', 'TEXT');
npcSay.setFieldValue('amused', 'MOOD');
const graph = buildScenarioGraph(ws);
const displayYaml = scenarioGraphToDisplayYaml(graph);
const parsed = YAML.parse(displayYaml);
// Verify scenes
expect(parsed.scenes).toHaveLength(2);
expect(parsed.scenes[0].name).toBe('SCENE_INTRO');
expect(parsed.scenes[0].duration_max_s).toBe(120);
expect(parsed.scenes[0].transitions).toHaveLength(1);
expect(parsed.scenes[0].transitions[0].target).toBe('SCENE_FINALE');
expect(parsed.scenes[1].name).toBe('SCENE_FINALE');
// Verify NPC
expect(parsed.npc).toBeDefined();
expect(parsed.npc).toHaveLength(1);
expect(parsed.npc[0].type).toBe('say');
expect(parsed.npc[0].text).toBe('Bienvenue dans le laboratoire!');
expect(parsed.npc[0].mood).toBe('amused');
// Verify YAML string contains key sections
expect(displayYaml).toContain('scenes:');
expect(displayYaml).toContain('SCENE_INTRO');
expect(displayYaml).toContain('SCENE_FINALE');
expect(displayYaml).toContain('npc:');
ws.dispose();
});
it('generates firmware YAML with correct steps structure', () => {
const ws = createWorkspace();
const scene1 = ws.newBlock('scenario_scene');
scene1.setFieldValue('SCENE_INTRO', 'NAME');
const trans = ws.newBlock('scenario_transition');
trans.setFieldValue('SCENE_FINALE', 'TARGET_SCENE');
connectStatement(scene1, 'TRANSITIONS', trans);
const scene2 = ws.newBlock('scenario_scene');
scene2.setFieldValue('SCENE_FINALE', 'NAME');
const graph = buildScenarioGraph(ws);
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const parsed = YAML.parse(firmwareYaml);
expect(parsed.firmware).toBeDefined();
expect(parsed.firmware.steps).toHaveLength(2);
expect(parsed.firmware.initial_step).toBe('SCENE_INTRO');
expect(parsed.firmware.steps[0].step_id).toBe('SCENE_INTRO');
expect(parsed.firmware.steps[0].transitions[0].target_step_id).toBe('SCENE_FINALE');
expect(parsed.firmware.steps[1].step_id).toBe('SCENE_FINALE');
ws.dispose();
});
});
// ─── Scenario 2: Branching puzzle ───
describe('YAML snapshot: branching puzzle scenario', () => {
it('generates display YAML with 3 scenes, puzzle with QR, 2 transitions, hints', () => {
const ws = createWorkspace();
// Scene 1: LOBBY with two transitions (success → LAB, timeout → GAMEOVER)
const scene1 = ws.newBlock('scenario_scene');
scene1.setFieldValue('SCENE_LOBBY', 'NAME');
scene1.setFieldValue('The lobby area', 'DESCRIPTION');
scene1.setFieldValue('300', 'DURATION_MAX');
const transSuccess = ws.newBlock('scenario_transition');
transSuccess.setFieldValue('SCENE_LAB', 'TARGET_SCENE');
const transTimeout = ws.newBlock('scenario_transition');
transTimeout.setFieldValue('SCENE_GAMEOVER', 'TARGET_SCENE');
connectStatement(scene1, 'TRANSITIONS', transSuccess);
chainBlocks(transSuccess, transTimeout);
// Scene 2: LAB
const scene2 = ws.newBlock('scenario_scene');
scene2.setFieldValue('SCENE_LAB', 'NAME');
scene2.setFieldValue('The secret laboratory', 'DESCRIPTION');
scene2.setFieldValue('600', 'DURATION_MAX');
// Scene 3: GAMEOVER
const scene3 = ws.newBlock('scenario_scene');
scene3.setFieldValue('SCENE_GAMEOVER', 'NAME');
scene3.setFieldValue('Time is up!', 'DESCRIPTION');
scene3.setFieldValue('30', 'DURATION_MAX');
// Puzzle with QR validation and hints
const puzzle = ws.newBlock('puzzle_definition');
puzzle.setFieldValue('PUZZLE_ENTRANCE', 'NAME');
puzzle.setFieldValue('qr', 'PUZZLE_TYPE');
const qrValidation = ws.newBlock('puzzle_validation_qr');
qrValidation.setFieldValue('ZACUS_DOOR_KEY', 'EXPECTED');
connectValue(puzzle, 'SOLUTION', qrValidation);
// Hints inside the puzzle
const hint1 = ws.newBlock('npc_hint');
hint1.setFieldValue('1', 'LEVEL');
hint1.setFieldValue('PUZZLE_ENTRANCE', 'PUZZLE_ID');
hint1.setFieldValue('Look at the painting on the wall', 'TEXT');
const hint2 = ws.newBlock('npc_hint');
hint2.setFieldValue('2', 'LEVEL');
hint2.setFieldValue('PUZZLE_ENTRANCE', 'PUZZLE_ID');
hint2.setFieldValue('The QR code is behind the frame', 'TEXT');
connectStatement(puzzle, 'HINTS', hint1);
chainBlocks(hint1, hint2);
const graph = buildScenarioGraph(ws);
const displayYaml = scenarioGraphToDisplayYaml(graph);
const parsed = YAML.parse(displayYaml);
// Scenes
expect(parsed.scenes).toHaveLength(3);
expect(parsed.scenes[0].name).toBe('SCENE_LOBBY');
expect(parsed.scenes[0].transitions).toHaveLength(2);
expect(parsed.scenes[0].transitions[0].target).toBe('SCENE_LAB');
expect(parsed.scenes[0].transitions[1].target).toBe('SCENE_GAMEOVER');
// Puzzle
expect(parsed.puzzles).toBeDefined();
expect(parsed.puzzles).toHaveLength(1);
expect(parsed.puzzles[0].name).toBe('PUZZLE_ENTRANCE');
expect(parsed.puzzles[0].type).toBe('qr');
expect(parsed.puzzles[0].solution).toBe('qr:ZACUS_DOOR_KEY');
expect(parsed.puzzles[0].hints).toHaveLength(2);
expect(parsed.puzzles[0].hints[0].level).toBe(1);
expect(parsed.puzzles[0].hints[0].text).toBe('Look at the painting on the wall');
expect(parsed.puzzles[0].hints[1].level).toBe(2);
// YAML string checks
expect(displayYaml).toContain('puzzles:');
expect(displayYaml).toContain('PUZZLE_ENTRANCE');
expect(displayYaml).toContain('qr:ZACUS_DOOR_KEY');
ws.dispose();
});
it('generates firmware YAML with puzzles section', () => {
const ws = createWorkspace();
const scene1 = ws.newBlock('scenario_scene');
scene1.setFieldValue('SCENE_LOBBY', 'NAME');
const puzzle = ws.newBlock('puzzle_definition');
puzzle.setFieldValue('PUZZLE_QR', 'NAME');
puzzle.setFieldValue('qr', 'PUZZLE_TYPE');
const qr = ws.newBlock('puzzle_validation_qr');
qr.setFieldValue('KEY_42', 'EXPECTED');
connectValue(puzzle, 'SOLUTION', qr);
const hint = ws.newBlock('npc_hint');
hint.setFieldValue('1', 'LEVEL');
hint.setFieldValue('PUZZLE_QR', 'PUZZLE_ID');
hint.setFieldValue('Check under the desk', 'TEXT');
connectStatement(puzzle, 'HINTS', hint);
const graph = buildScenarioGraph(ws);
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const parsed = YAML.parse(firmwareYaml);
expect(parsed.firmware.steps).toHaveLength(1);
expect(parsed.firmware.puzzles).toBeDefined();
expect(parsed.firmware.puzzles).toHaveLength(1);
expect(parsed.firmware.puzzles[0].puzzle_id).toBe('PUZZLE_QR');
expect(parsed.firmware.puzzles[0].type).toBe('qr');
expect(parsed.firmware.puzzles[0].solution).toBe('qr:KEY_42');
expect(parsed.firmware.puzzles[0].hints_count).toBe(1);
ws.dispose();
});
});
// ─── Scenario 3: Full escape room ───
describe('YAML snapshot: full escape room scenario', () => {
it('generates display YAML with all sections', () => {
const ws = createWorkspace();
// Scene 1: INTRO with timer action
const scene1 = ws.newBlock('scenario_scene');
scene1.setFieldValue('SCENE_INTRO', 'NAME');
scene1.setFieldValue('The adventure begins', 'DESCRIPTION');
scene1.setFieldValue('180', 'DURATION_MAX');
const timer = ws.newBlock('scenario_timer');
timer.setFieldValue('60', 'SECONDS');
const varSet = ws.newBlock('scenario_variable_set');
varSet.setFieldValue('intro_timeout', 'NAME');
connectStatement(timer, 'ON_EXPIRE', varSet);
connectStatement(scene1, 'ACTIONS', timer);
const trans1 = ws.newBlock('scenario_transition');
trans1.setFieldValue('SCENE_LAB', 'TARGET_SCENE');
connectStatement(scene1, 'TRANSITIONS', trans1);
// Scene 2: LAB
const scene2 = ws.newBlock('scenario_scene');
scene2.setFieldValue('SCENE_LAB', 'NAME');
scene2.setFieldValue('Professor Zacus laboratory', 'DESCRIPTION');
scene2.setFieldValue('600', 'DURATION_MAX');
const trans2 = ws.newBlock('scenario_transition');
trans2.setFieldValue('SCENE_ESCAPE', 'TARGET_SCENE');
connectStatement(scene2, 'TRANSITIONS', trans2);
// Scene 3: ESCAPE
const scene3 = ws.newBlock('scenario_scene');
scene3.setFieldValue('SCENE_ESCAPE', 'NAME');
scene3.setFieldValue('Find the exit!', 'DESCRIPTION');
scene3.setFieldValue('300', 'DURATION_MAX');
// Puzzle 1: QR
const puzzle1 = ws.newBlock('puzzle_definition');
puzzle1.setFieldValue('PUZZLE_DOOR', 'NAME');
puzzle1.setFieldValue('qr', 'PUZZLE_TYPE');
const qr = ws.newBlock('puzzle_validation_qr');
qr.setFieldValue('ZACUS_EXIT', 'EXPECTED');
connectValue(puzzle1, 'SOLUTION', qr);
const hint = ws.newBlock('npc_hint');
hint.setFieldValue('1', 'LEVEL');
hint.setFieldValue('PUZZLE_DOOR', 'PUZZLE_ID');
hint.setFieldValue('Search the bookshelf', 'TEXT');
connectStatement(puzzle1, 'HINTS', hint);
// Puzzle 2: Button
const puzzle2 = ws.newBlock('puzzle_definition');
puzzle2.setFieldValue('PUZZLE_SAFE', 'NAME');
puzzle2.setFieldValue('button', 'PUZZLE_TYPE');
const btnVal = ws.newBlock('puzzle_validation_button');
btnVal.setFieldValue('12', 'PIN');
connectValue(puzzle2, 'SOLUTION', btnVal);
// NPC dialogue
const npcSay = ws.newBlock('npc_say');
npcSay.setFieldValue('Bienvenue dans mon laboratoire!', 'TEXT');
npcSay.setFieldValue('impressed', 'MOOD');
// Hardware: GPIO write
const gpioWrite = ws.newBlock('hw_gpio_write');
gpioWrite.setFieldValue('15', 'PIN');
gpioWrite.setFieldValue('HIGH', 'STATE');
// Deploy config: WiFi + TTS + LLM
const wifi = ws.newBlock('deploy_config_wifi');
wifi.setFieldValue('ZacusNet', 'SSID');
wifi.setFieldValue('secret123', 'PASSWORD');
const tts = ws.newBlock('deploy_config_tts');
tts.setFieldValue('http://192.168.0.120:8001', 'URL');
tts.setFieldValue('tom-medium', 'VOICE');
const llm = ws.newBlock('deploy_config_llm');
llm.setFieldValue('http://kxkm-ai:11434', 'URL');
llm.setFieldValue('devstral', 'MODEL');
const graph = buildScenarioGraph(ws);
const displayYaml = scenarioGraphToDisplayYaml(graph);
const parsed = YAML.parse(displayYaml);
// Scenes
expect(parsed.scenes).toHaveLength(3);
expect(parsed.scenes[0].name).toBe('SCENE_INTRO');
expect(parsed.scenes[0].actions).toHaveLength(1);
expect(parsed.scenes[0].actions[0].type).toBe('timer');
expect(parsed.scenes[0].actions[0].seconds).toBe(60);
// Puzzles
expect(parsed.puzzles).toHaveLength(2);
expect(parsed.puzzles[0].name).toBe('PUZZLE_DOOR');
expect(parsed.puzzles[0].solution).toBe('qr:ZACUS_EXIT');
expect(parsed.puzzles[1].name).toBe('PUZZLE_SAFE');
expect(parsed.puzzles[1].type).toBe('button');
expect(parsed.puzzles[1].solution).toBe('button:12');
// NPC
expect(parsed.npc).toBeDefined();
expect(parsed.npc.length).toBeGreaterThanOrEqual(1);
const sayAction = parsed.npc.find(
(a: { type: string }) => a.type === 'say',
);
expect(sayAction).toBeDefined();
expect(sayAction.text).toBe('Bienvenue dans mon laboratoire!');
// Hardware
expect(parsed.hardware).toBeDefined();
expect(parsed.hardware.length).toBeGreaterThanOrEqual(1);
const gpio = parsed.hardware.find(
(a: { type: string }) => a.type === 'gpio_write',
);
expect(gpio).toBeDefined();
expect(gpio.pin).toBe(15);
expect(gpio.state).toBe('HIGH');
// Deploy
expect(parsed.deploy).toBeDefined();
expect(parsed.deploy.wifi).toEqual({ ssid: 'ZacusNet', password: 'secret123' });
expect(parsed.deploy.tts).toEqual({
url: 'http://192.168.0.120:8001',
voice: 'tom-medium',
});
expect(parsed.deploy.llm).toEqual({
url: 'http://kxkm-ai:11434',
model: 'devstral',
});
// Verify all display YAML sections exist
expect(displayYaml).toContain('scenes:');
expect(displayYaml).toContain('puzzles:');
expect(displayYaml).toContain('npc:');
expect(displayYaml).toContain('hardware:');
expect(displayYaml).toContain('deploy:');
ws.dispose();
});
it('generates firmware YAML with hardware actions attached to first step', () => {
const ws = createWorkspace();
const scene1 = ws.newBlock('scenario_scene');
scene1.setFieldValue('SCENE_BOOT', 'NAME');
const scene2 = ws.newBlock('scenario_scene');
scene2.setFieldValue('SCENE_PLAY', 'NAME');
// Hardware
const gpio = ws.newBlock('hw_gpio_write');
gpio.setFieldValue('15', 'PIN');
gpio.setFieldValue('HIGH', 'STATE');
const led = ws.newBlock('hw_led_set');
led.setFieldValue('#FF0000', 'COLOR');
led.setFieldValue('blink', 'ANIMATION');
// Deploy
const wifi = ws.newBlock('deploy_config_wifi');
wifi.setFieldValue('EscapeNet', 'SSID');
wifi.setFieldValue('pass', 'PASSWORD');
const graph = buildScenarioGraph(ws);
const firmwareYaml = scenarioGraphToFirmwareYaml(graph);
const parsed = YAML.parse(firmwareYaml);
// firmware.steps[] structure
expect(parsed.firmware).toBeDefined();
expect(parsed.firmware.steps).toHaveLength(2);
expect(parsed.firmware.initial_step).toBe('SCENE_BOOT');
// Hardware actions on first step
expect(parsed.firmware.steps[0].actions).toHaveLength(2);
expect(parsed.firmware.steps[0].actions[0].type).toBe('gpio_write');
expect(parsed.firmware.steps[0].actions[0].pin).toBe(15);
expect(parsed.firmware.steps[0].actions[1].type).toBe('led_set');
expect(parsed.firmware.steps[0].actions[1].color).toBe('#FF0000');
// Deploy config in firmware
expect(parsed.firmware.deploy).toBeDefined();
expect(parsed.firmware.deploy.wifi.ssid).toBe('EscapeNet');
ws.dispose();
});
});
@@ -1,170 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
buildScenarioFromBlocks,
validateScenarioDocument,
normalizeId,
} from '../lib/scenario';
import type { ScenarioStep, ScenarioDocument } from '../types';
// ─── Helpers ───
function makeValidSteps(overrides?: Partial<ScenarioStep>[]): ScenarioStep[] {
const defaults: ScenarioStep[] = [
{
stepId: 'STEP_A',
sceneId: 'SCENE_A',
transitions: [
{
eventType: 'button',
eventName: 'BTN_NEXT',
targetStepId: 'STEP_B',
priority: 0,
afterMs: 0,
},
],
},
{
stepId: 'STEP_B',
sceneId: 'SCENE_B',
transitions: [],
},
];
if (!overrides) return defaults;
return overrides.map((o, i) => ({ ...defaults[i % defaults.length], ...o }));
}
function makeValidDocument(
steps?: ScenarioStep[],
scenarioId = 'TEST_SCENARIO',
): ScenarioDocument {
return buildScenarioFromBlocks(scenarioId, steps ?? makeValidSteps());
}
// ─── Tests ───
describe('scenario validation', () => {
it('valid scenario passes validation', () => {
const doc = makeValidDocument();
const result = validateScenarioDocument(doc);
expect(result).toEqual({ ok: true });
});
it('missing required field (empty id) fails', () => {
const doc = makeValidDocument(makeValidSteps(), '');
// buildScenarioFromBlocks normalizes empty to fallback, so force it
doc.id = '';
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
});
it('missing title fails', () => {
const doc = makeValidDocument();
doc.title = '';
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
});
it('duplicate step IDs detected', () => {
const doc = makeValidDocument();
// Manually inject duplicate step IDs into firmware
if (doc.firmware?.steps) {
doc.firmware.steps.push({ ...doc.firmware.steps[0] });
doc.firmware.steps_reference_order = doc.firmware.steps.map(
(s) => s.step_id,
);
}
doc.steps_narrative.push({ ...doc.steps_narrative[0] });
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
expect('error' in result && result.error).toContain('duplicate');
});
it('transition target pointing to existing step passes', () => {
const steps: ScenarioStep[] = [
{
stepId: 'STEP_1',
sceneId: 'SCENE_1',
transitions: [
{
eventType: 'serial',
eventName: 'BTN_GO',
targetStepId: 'STEP_2',
priority: 0,
afterMs: 0,
},
],
},
{ stepId: 'STEP_2', sceneId: 'SCENE_2', transitions: [] },
];
const doc = makeValidDocument(steps);
expect(validateScenarioDocument(doc)).toEqual({ ok: true });
});
it('transition target pointing to non-existing step fails', () => {
const steps: ScenarioStep[] = [
{
stepId: 'STEP_1',
sceneId: 'SCENE_1',
transitions: [
{
eventType: 'serial',
eventName: 'BTN_GO',
targetStepId: 'STEP_GHOST',
priority: 0,
afterMs: 0,
},
],
},
];
const doc = makeValidDocument(steps);
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain('STEP_GHOST');
}
});
it('players.min > players.max fails', () => {
const doc = makeValidDocument();
doc.players = { min: 20, max: 5 };
const result = validateScenarioDocument(doc);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain('players');
}
});
it('empty steps array produces fallback STEP_BOOT', () => {
const doc = makeValidDocument([]);
expect(doc.firmware?.steps).toHaveLength(1);
expect(doc.firmware?.steps?.[0].step_id).toBe('STEP_BOOT');
expect(validateScenarioDocument(doc)).toEqual({ ok: true });
});
});
describe('normalizeId', () => {
it('empty string returns fallback', () => {
expect(normalizeId('', 'FALLBACK')).toBe('FALLBACK');
});
it('whitespace-only returns fallback', () => {
expect(normalizeId(' ', 'FB')).toBe('FB');
});
it('special characters are replaced with underscores', () => {
expect(normalizeId('hello-world!@#', 'X')).toBe('HELLO_WORLD');
});
it('unicode characters are stripped', () => {
const result = normalizeId('etape_cafe', 'X');
expect(result).toBe('ETAPE_CAFE');
});
it('leading/trailing underscores are removed', () => {
expect(normalizeId('__test__', 'X')).toBe('TEST');
});
it('already valid ID is uppercased', () => {
expect(normalizeId('step_one', 'X')).toBe('STEP_ONE');
});
});
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,389 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as Blockly from 'blockly';
import 'blockly/blocks';
import {
buildScenarioFromBlocks,
scenarioToYaml,
validateScenarioDocument,
parseYamlToSteps,
} from '../lib/scenario';
import {
compileScenarioDocumentToRuntime3,
runtime3ToJson,
validateRuntime3Document,
} from '../lib/runtime3';
import type { ScenarioStep } from '../types';
const TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'Steps',
colour: '#3b82f6',
contents: [{ kind: 'block', type: 'zacus_step' }],
},
{
kind: 'category',
name: 'Transitions',
colour: '#22c55e',
contents: [{ kind: 'block', type: 'zacus_transition' }],
},
],
};
let blocksRegistered = false;
function ensureCustomBlocks(): void {
if (blocksRegistered) return;
Blockly.defineBlocksWithJsonArray([
{
type: 'zacus_step',
message0: 'Step %1 Scene %2 Audio %3',
args0: [
{ type: 'field_input', name: 'STEP_ID', text: 'STEP_NEW' },
{ type: 'field_input', name: 'SCENE_ID', text: 'SCENE_NEW' },
{ type: 'field_input', name: 'AUDIO_PACK', text: '' },
],
message1: '%1',
args1: [{ type: 'input_statement', name: 'TRANSITIONS' }],
previousStatement: null,
nextStatement: null,
colour: 210,
tooltip: 'A scenario step with its screen scene and audio pack',
},
{
type: 'zacus_transition',
message0: 'on %1 : %2 → %3 after %4 ms prio %5',
args0: [
{
type: 'field_dropdown',
name: 'EVENT_TYPE',
options: [
['button', 'button'],
['serial', 'serial'],
['timer', 'timer'],
['audio_done', 'audio_done'],
['unlock', 'unlock'],
['espnow', 'espnow'],
['action', 'action'],
],
},
{ type: 'field_input', name: 'EVENT_NAME', text: 'BTN_NEXT' },
{ type: 'field_input', name: 'TARGET', text: 'STEP_NEXT' },
{ type: 'field_number', name: 'AFTER_MS', value: 0, min: 0, precision: 1 },
{ type: 'field_number', name: 'PRIORITY', value: 0, min: 0, precision: 1 },
],
previousStatement: null,
nextStatement: null,
colour: 140,
tooltip: 'A transition triggered by an event',
},
]);
blocksRegistered = true;
}
function readScenarioSteps(workspace: Blockly.WorkspaceSvg): ScenarioStep[] {
const steps: ScenarioStep[] = [];
const seenBlocks = new Set<string>();
const readTransitions = (block: Blockly.Block): ScenarioStep['transitions'] => {
const transitions: NonNullable<ScenarioStep['transitions']> = [];
let transitionBlock = block.getInputTargetBlock('TRANSITIONS');
while (transitionBlock) {
if (transitionBlock.type === 'zacus_transition') {
transitions.push({
eventType: transitionBlock.getFieldValue('EVENT_TYPE') ?? 'serial',
eventName: transitionBlock.getFieldValue('EVENT_NAME') ?? 'BTN_NEXT',
targetStepId: transitionBlock.getFieldValue('TARGET') ?? 'STEP_NEXT',
afterMs: Number(transitionBlock.getFieldValue('AFTER_MS') ?? 0),
priority: Number(transitionBlock.getFieldValue('PRIORITY') ?? 0),
});
}
transitionBlock = transitionBlock.getNextBlock();
}
return transitions;
};
const collectChain = (startBlock: Blockly.Block): void => {
let cursor: Blockly.Block | null = startBlock;
while (cursor) {
if (seenBlocks.has(cursor.id)) break;
seenBlocks.add(cursor.id);
if (cursor.type === 'zacus_step') {
steps.push({
stepId: cursor.getFieldValue('STEP_ID') ?? '',
sceneId: cursor.getFieldValue('SCENE_ID') ?? '',
audioPack: cursor.getFieldValue('AUDIO_PACK') ?? '',
transitions: readTransitions(cursor),
});
}
cursor = cursor.getNextBlock();
}
};
for (const block of workspace.getTopBlocks(true)) {
if (block.type === 'zacus_step') collectChain(block);
}
if (steps.length === 0) {
for (const block of workspace.getAllBlocks(false)) {
if (block.type === 'zacus_step') collectChain(block);
}
}
// Deduplicate step IDs — keep first occurrence, warn on duplicates
const seenStepIds = new Set<string>();
const deduped: ScenarioStep[] = [];
for (const step of steps) {
if (seenStepIds.has(step.stepId)) {
console.warn(
`[BlocklyDesigner] Duplicate step ID "${step.stepId}" detected — skipping duplicate.`,
);
continue;
}
seenStepIds.add(step.stepId);
deduped.push(step);
}
return deduped;
}
function loadStepsIntoWorkspace(
workspace: Blockly.WorkspaceSvg,
steps: ScenarioStep[],
): void {
workspace.clear();
let prevBlock: Blockly.Block | null = null;
const Y_START = 40;
const X_START = 40;
for (const step of steps) {
const block = workspace.newBlock('zacus_step');
block.setFieldValue(step.stepId || 'STEP_NEW', 'STEP_ID');
block.setFieldValue(step.sceneId || 'SCENE_NEW', 'SCENE_ID');
block.setFieldValue(step.audioPack || '', 'AUDIO_PACK');
block.initSvg();
block.render();
const transitions = step.transitions ?? [];
let previousTransitionBlock: Blockly.Block | null = null;
for (const transition of transitions) {
const transitionBlock = workspace.newBlock('zacus_transition');
transitionBlock.setFieldValue(transition.eventType, 'EVENT_TYPE');
transitionBlock.setFieldValue(transition.eventName, 'EVENT_NAME');
transitionBlock.setFieldValue(transition.targetStepId, 'TARGET');
transitionBlock.setFieldValue(String(transition.afterMs), 'AFTER_MS');
transitionBlock.setFieldValue(String(transition.priority), 'PRIORITY');
transitionBlock.initSvg();
transitionBlock.render();
if (!previousTransitionBlock) {
const inputConnection = block.getInput('TRANSITIONS')?.connection;
if (inputConnection && transitionBlock.previousConnection) {
inputConnection.connect(transitionBlock.previousConnection);
}
} else if (
previousTransitionBlock.nextConnection &&
transitionBlock.previousConnection
) {
previousTransitionBlock.nextConnection.connect(
transitionBlock.previousConnection,
);
}
previousTransitionBlock = transitionBlock;
}
if (prevBlock) {
if (prevBlock.nextConnection && block.previousConnection) {
prevBlock.nextConnection.connect(block.previousConnection);
}
} else {
block.moveBy(X_START, Y_START);
}
prevBlock = block;
}
}
function addStarterBlocks(workspace: Blockly.WorkspaceSvg): void {
loadStepsIntoWorkspace(workspace, [
{
stepId: 'STEP_U_SON_PROTO',
sceneId: 'SCENE_U_SON_PROTO',
transitions: [
{
eventType: 'button',
eventName: 'ANY',
targetStepId: 'STEP_LA_DETECTOR',
priority: 0,
afterMs: 0,
},
],
},
{ stepId: 'STEP_LA_DETECTOR', sceneId: 'SCENE_LA_DETECTOR', transitions: [] },
]);
}
type BlocklyDesignerProps = {
onDraftChange: (draft: { yaml: string; runtime3Json: string }) => void;
};
export function BlocklyDesigner({ onDraftChange }: BlocklyDesignerProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const [scenarioId, setScenarioId] = useState('zacus_v2_new');
const [steps, setSteps] = useState<ScenarioStep[]>([]);
const [copyInfo, setCopyInfo] = useState('');
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!hostRef.current) return;
ensureCustomBlocks();
const workspace = Blockly.inject(hostRef.current, {
toolbox: TOOLBOX,
trashcan: true,
grid: { spacing: 20, length: 3, colour: '#3a3f52', snap: true },
zoom: { controls: true, wheel: true, startScale: 0.95 },
});
workspaceRef.current = workspace;
addStarterBlocks(workspace);
const onChange = () => {
setSteps(readScenarioSteps(workspace));
setCopyInfo('');
};
workspace.addChangeListener(onChange);
onChange();
return () => {
workspace.removeChangeListener(onChange);
workspace.dispose();
workspaceRef.current = null;
};
}, []);
const generated = useMemo(() => {
const scenarioDocument = buildScenarioFromBlocks(scenarioId, steps);
const runtime3Document = compileScenarioDocumentToRuntime3(scenarioDocument);
return {
scenarioDocument,
yaml: scenarioToYaml(scenarioDocument),
validation: validateScenarioDocument(scenarioDocument),
runtime3Json: runtime3ToJson(runtime3Document),
runtime3Validation: validateRuntime3Document(runtime3Document),
};
}, [scenarioId, steps]);
useEffect(() => {
onDraftChange({
yaml: generated.yaml,
runtime3Json: generated.runtime3Json,
});
}, [generated.runtime3Json, generated.yaml, onDraftChange]);
const handleReset = () => {
if (!workspaceRef.current) return;
addStarterBlocks(workspaceRef.current);
setSteps(readScenarioSteps(workspaceRef.current));
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(generated.yaml);
setCopyInfo('YAML copied.');
} catch {
setCopyInfo('Copy failed.');
}
};
const handleImportYaml = (yamlStr: string) => {
const result = parseYamlToSteps(yamlStr);
if ('error' in result) {
setCopyInfo(`Import error: ${result.error}`);
return;
}
setScenarioId(result.id);
if (workspaceRef.current) {
loadStepsIntoWorkspace(workspaceRef.current, result.steps);
setSteps(readScenarioSteps(workspaceRef.current));
}
setCopyInfo(`Imported ${result.steps.length} steps from "${result.id}".`);
};
const handleFileImport = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
handleImportYaml(reader.result);
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<>
<div className="toolbar">
<input
type="text"
value={scenarioId}
onChange={(event) => setScenarioId(event.target.value)}
aria-label="scenario id"
placeholder="scenario id"
/>
<button type="button" onClick={handleReset}>
Reset
</button>
<button type="button" onClick={handleCopy}>
Copy YAML
</button>
<button type="button" onClick={handleFileImport}>
Import YAML
</button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
<div ref={hostRef} className="blockly-host" />
<div
className={`status-line ${
generated.validation.ok && generated.runtime3Validation.ok ? '' : 'error'
}`}
role="status"
>
{generated.validation.ok && generated.runtime3Validation.ok
? `Ready — ${steps.length} step(s), ${
steps.reduce(
(total, step) => total + (step.transitions?.length ?? 0),
0,
)
} transition(s). ${copyInfo}`
: `Invalid: ${
!generated.validation.ok
? generated.validation.error
: generated.runtime3Validation.ok
? 'runtime3 validation error'
: generated.runtime3Validation.error
}`}
</div>
</>
);
}
@@ -1,534 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import {
type ScenarioListItem,
type VoiceStatus,
type GameAnalytics,
storyList,
storySelect,
storyStart,
storyPause,
storyResume,
storySkip,
askHint,
voiceStatus,
gameAnalytics,
} from '../lib/api';
import type { RuntimeState } from '../lib/useRuntimeStore';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ChatMessage {
role: 'user' | 'zacus';
text: string;
timestamp: number;
}
type Props = { runtime: RuntimeState; refresh: () => void };
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function Dashboard({ runtime, refresh }: Props) {
const [scenarios, setScenarios] = useState<ScenarioListItem[]>([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState('');
const [voiceInput, setVoiceInput] = useState('');
const [voice, setVoice] = useState<VoiceStatus | null>(null);
// Chat history
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [chatLoading, setChatLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Hint level
const [hintLevel, setHintLevel] = useState<number>(1);
// Analytics
const [analytics, setAnalytics] = useState<GameAnalytics | null>(null);
const [analyticsError, setAnalyticsError] = useState('');
// ─── Initial load ───
useEffect(() => {
storyList()
.then(setScenarios)
.catch(() => setScenarios([]));
voiceStatus()
.then(setVoice)
.catch(() => setVoice(null));
}, [runtime.connected]);
// ─── Auto-poll voice status every 5s when connected ───
useEffect(() => {
if (!runtime.connected) return;
const id = setInterval(() => {
voiceStatus().then(setVoice).catch(() => {});
}, 5000);
return () => clearInterval(id);
}, [runtime.connected]);
// ─── Scroll chat to bottom ───
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatHistory, chatLoading]);
// ─── Helpers ───
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(true);
setFeedback('');
try {
await fn();
setFeedback(`${label} OK`);
refresh();
} catch (err) {
setFeedback(
`${label} failed: ${err instanceof Error ? err.message : err}`,
);
} finally {
setBusy(false);
}
};
const handleAskZacus = async () => {
const text = voiceInput.trim();
if (!text) return;
const userMsg: ChatMessage = { role: 'user', text, timestamp: Date.now() };
setChatHistory((prev) => [...prev, userMsg]);
setVoiceInput('');
setChatLoading(true);
try {
const puzzleId = runtime.story?.current_step || 'general';
const res = await askHint(puzzleId, text, hintLevel);
const zacusMsg: ChatMessage = {
role: 'zacus',
text: res.hint,
timestamp: Date.now(),
};
setChatHistory((prev) => [...prev, zacusMsg]);
} catch (err) {
const errorMsg: ChatMessage = {
role: 'zacus',
text: `[Error] ${err instanceof Error ? err.message : String(err)}`,
timestamp: Date.now(),
};
setChatHistory((prev) => [...prev, errorMsg]);
} finally {
setChatLoading(false);
}
};
const refreshAnalytics = () => {
setAnalyticsError('');
gameAnalytics()
.then(setAnalytics)
.catch((err) =>
setAnalyticsError(err instanceof Error ? err.message : String(err)),
);
};
const formatDuration = (ms: number) => {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}m ${sec}s`;
};
const story = runtime.story;
const legacy = runtime.legacy;
const net = legacy?.network;
const runtime3 = legacy?.runtime3;
return (
<div className="dashboard">
<h2>Dashboard</h2>
{/* Connection */}
<section className="card">
<h3>Connection</h3>
<div className="kv">
<span>Status</span>
<span className={runtime.connected ? 'badge ok' : 'badge err'}>
{runtime.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{net && (
<>
<div className="kv">
<span>Network</span>
<span>{net.state ?? '?'}</span>
</div>
<div className="kv">
<span>IP</span>
<span>{net.ip ?? '?'}</span>
</div>
</>
)}
</section>
{/* Story status */}
<section className="card">
<h3>Story runtime</h3>
{story ? (
<>
<div className="kv">
<span>Status</span>
<span className="badge">{story.status}</span>
</div>
<div className="kv">
<span>Scenario</span>
<span>{story.scenario_id || '\u2014'}</span>
</div>
<div className="kv">
<span>Step</span>
<span className="mono">{story.current_step || '\u2014'}</span>
</div>
<div className="kv">
<span>Progress</span>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${story.progress_pct}%` }}
/>
<span>{story.progress_pct}%</span>
</div>
</div>
<div className="kv">
<span>Queue</span>
<span>{story.queue_depth}</span>
</div>
</>
) : (
<p className="muted">No Story V2 status available.</p>
)}
</section>
<section className="card">
<h3>Runtime 3 adapter</h3>
{runtime3 ? (
<div className="runtime3-grid">
<div className="kv">
<span>Contract</span>
<span className="mono">
{legacy?.story.runtime_contract ?? 'story_v2'}
</span>
</div>
<div className="kv">
<span>Status</span>
<span
className={`badge ${
runtime3.loaded ? 'ok' : runtime3.discovered ? 'active' : 'err'
}`}
>
{runtime3.loaded
? 'Loaded'
: runtime3.discovered
? 'Discovered'
: 'Missing'}
</span>
</div>
<div className="kv">
<span>Scenario</span>
<span className="mono">{runtime3.scenario_id || '\u2014'}</span>
</div>
<div className="kv">
<span>Version</span>
<span>{runtime3.scenario_version || 0}</span>
</div>
<div className="kv">
<span>Entry step</span>
<span className="mono">{runtime3.entry_step_id || '\u2014'}</span>
</div>
<div className="kv">
<span>Migration</span>
<span className="mono">{runtime3.migration_mode || '\u2014'}</span>
</div>
<div className="kv">
<span>Steps</span>
<span>{runtime3.step_count}</span>
</div>
<div className="kv">
<span>Transitions</span>
<span>{runtime3.transition_count}</span>
</div>
<div className="kv">
<span>Source</span>
<span className="mono">{runtime3.source_kind || '\u2014'}</span>
</div>
<div className="kv">
<span>Schema</span>
<span className="mono">{runtime3.schema_version || '\u2014'}</span>
</div>
<div className="kv runtime3-span">
<span>Bundle</span>
<span className="mono runtime3-path">{runtime3.path || '\u2014'}</span>
</div>
{runtime3.error && (
<div className="notice warn runtime3-span">
Runtime 3 error: {runtime3.error}
</div>
)}
</div>
) : (
<p className="muted">No Runtime 3 adapter metadata available.</p>
)}
</section>
{/* Scenarios */}
<section className="card">
<h3>Scenarios</h3>
{scenarios.length === 0 ? (
<p className="muted">No scenarios found.</p>
) : (
<ul className="scenario-list">
{scenarios.map((s) => (
<li key={s.id}>
<span className="mono">{s.id}</span>
<span className="muted">v{s.version}</span>
<button
type="button"
disabled={busy || story?.selected === s.id}
onClick={() => run('Select', () => storySelect(s.id))}
>
{story?.selected === s.id ? 'Selected' : 'Select'}
</button>
</li>
))}
</ul>
)}
</section>
{/* Controls */}
<section className="card">
<h3>Controls</h3>
<div className="actions">
<button
type="button"
disabled={busy || story?.status === 'running'}
onClick={() => run('Start', storyStart)}
>
Start
</button>
<button
type="button"
disabled={busy || story?.status !== 'running'}
onClick={() => run('Pause', storyPause)}
>
Pause
</button>
<button
type="button"
disabled={busy || story?.status !== 'paused'}
onClick={() => run('Resume', storyResume)}
>
Resume
</button>
<button
type="button"
disabled={busy || story?.status !== 'running'}
onClick={() => run('Skip', storySkip)}
>
Skip
</button>
</div>
{feedback && <p className="feedback">{feedback}</p>}
</section>
{/* Voice — Ask Professor Zacus (enhanced chat) */}
<section className="card">
<h3>Ask Professor Zacus</h3>
<div className="kv">
<span>Voice Bridge</span>
<span className={voice?.connected ? 'badge ok' : 'badge err'}>
{voice?.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Hint level selector */}
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', margin: '0.5rem 0' }}>
<span style={{ fontSize: '0.85em', fontWeight: 500 }}>Hint level:</span>
{[1, 2, 3].map((lvl) => (
<label key={lvl} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.85em', cursor: 'pointer' }}>
<input
type="radio"
name="hintLevel"
value={lvl}
checked={hintLevel === lvl}
onChange={() => setHintLevel(lvl)}
/>
{lvl === 1 ? 'Gentle' : lvl === 2 ? 'Medium' : 'Direct'}
</label>
))}
</div>
{/* Chat history */}
<div
style={{
maxHeight: '240px',
overflowY: 'auto',
border: '1px solid var(--border, #ddd)',
borderRadius: '6px',
padding: '0.5rem',
marginBottom: '0.5rem',
background: 'var(--bg-muted, #fafafa)',
}}
>
{chatHistory.length === 0 && !chatLoading && (
<p className="muted" style={{ textAlign: 'center', margin: '1rem 0', fontSize: '0.85em' }}>
Ask Professor Zacus a question about the puzzle...
</p>
)}
{chatHistory.map((msg, i) => (
<div
key={i}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: '0.4rem',
}}
>
<div
style={{
maxWidth: '80%',
padding: '0.4rem 0.6rem',
borderRadius: '8px',
background: msg.role === 'user'
? 'var(--accent, #6c5ce7)'
: 'var(--bg-card, #fff)',
color: msg.role === 'user' ? '#fff' : 'inherit',
border: msg.role === 'zacus' ? '1px solid var(--border, #ddd)' : 'none',
fontSize: '0.9em',
whiteSpace: 'pre-wrap',
}}
>
{msg.role === 'zacus' && (
<strong style={{ fontSize: '0.8em', display: 'block', marginBottom: '0.15rem' }}>
Prof. Zacus
</strong>
)}
{msg.text}
</div>
<span style={{ fontSize: '0.7em', color: '#999', marginTop: '0.1rem' }}>
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
{chatLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.4rem' }}>
<span
className="spinner"
style={{
display: 'inline-block',
width: '14px',
height: '14px',
border: '2px solid var(--border, #ddd)',
borderTopColor: 'var(--accent, #6c5ce7)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
<span style={{ fontSize: '0.85em', color: '#999' }}>Professor Zacus is thinking...</span>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Hint count */}
{analytics && (
<div style={{ fontSize: '0.8em', color: '#888', marginBottom: '0.3rem' }}>
Hints used this session: {analytics.total_hints}
</div>
)}
{/* Input */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text"
placeholder="Ask a question..."
aria-label="Voice query"
value={voiceInput}
onChange={(e) => setVoiceInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && voiceInput.trim() && !chatLoading) {
handleAskZacus();
}
}}
disabled={chatLoading}
style={{ flex: 1 }}
/>
<button
type="button"
disabled={chatLoading || !voiceInput.trim()}
onClick={handleAskZacus}
>
Ask
</button>
</div>
</section>
{/* Analytics */}
<section className="card">
<h3>
Analytics{' '}
<button
type="button"
onClick={refreshAnalytics}
style={{ fontSize: '0.75em', marginLeft: '0.5rem', padding: '0.15rem 0.5rem' }}
>
Refresh
</button>
</h3>
{analyticsError && <p className="error-banner">{analyticsError}</p>}
{analytics ? (
<div className="runtime3-grid">
<div className="kv">
<span>Session</span>
<span className="mono">{analytics.session_id}</span>
</div>
<div className="kv">
<span>Duration</span>
<span>{formatDuration(analytics.duration_ms)}</span>
</div>
<div className="kv">
<span>Puzzles solved</span>
<span>
{analytics.puzzles_solved} / {analytics.puzzles.length}
</span>
</div>
<div className="kv">
<span>Hints used</span>
<span>{analytics.total_hints}</span>
</div>
<div className="kv">
<span>Total attempts</span>
<span>{analytics.total_attempts}</span>
</div>
</div>
) : (
<p className="muted">No analytics loaded. Press Refresh to fetch.</p>
)}
</section>
{/* Last WS event */}
{runtime.lastEvent && (
<section className="card">
<h3>Last event</h3>
<pre className="event-json">
{JSON.stringify(runtime.lastEvent, null, 2)}
</pre>
</section>
)}
{runtime.lastError && (
<p className="error-banner">{runtime.lastError}</p>
)}
{/* Spinner keyframe (injected once) */}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
@@ -1,284 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
type MediaFileList,
type MediaStatus,
mediaFiles,
mediaPlay,
mediaStop,
mediaRecordStart,
mediaRecordStop,
legacyControl,
} from '../lib/api';
import type { RuntimeState } from '../lib/useRuntimeStore';
import { isMediaManagerActive } from '../lib/useRuntimeStore';
type Props = { runtime: RuntimeState };
type FileCategory = 'music' | 'picture' | 'recorder';
const CATEGORIES: FileCategory[] = ['music', 'picture', 'recorder'];
export function MediaManager({ runtime }: Props) {
const [listings, setListings] = useState<
Record<FileCategory, MediaFileList | null>
>({
music: null,
picture: null,
recorder: null,
});
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState('');
const [recSeconds, setRecSeconds] = useState(20);
const [recFilename, setRecFilename] = useState('take_1.wav');
const media: MediaStatus | undefined = runtime.legacy?.media;
const active = isMediaManagerActive(runtime.legacy);
const loadFiles = useCallback(async () => {
const results = await Promise.allSettled(
CATEGORIES.map((kind) => mediaFiles(kind)),
);
const next = { ...listings };
CATEGORIES.forEach((kind, i) => {
const r = results[i];
next[kind] = r.status === 'fulfilled' ? r.value : null;
});
setListings(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (active) loadFiles();
}, [active, loadFiles]);
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(true);
setFeedback('');
try {
await fn();
setFeedback(`${label} OK`);
} catch (err) {
setFeedback(
`${label} failed: ${err instanceof Error ? err.message : err}`,
);
} finally {
setBusy(false);
}
};
const handlePlay = (path: string) =>
run('Play', async () => {
if (media?.playing) await mediaStop();
await mediaPlay(path);
});
const handleStop = () => run('Stop', mediaStop);
const handleRecordStart = () =>
run('Record', () => mediaRecordStart(recSeconds, recFilename));
const handleRecordStop = () => run('RecStop', mediaRecordStop);
return (
<div className="media-manager">
<h2>Media Manager</h2>
{!active && (
<div className="notice warn">
Media Manager not active on device. Current step:{' '}
<span className="mono">
{runtime.legacy?.story.screen ?? runtime.legacy?.story.step ?? '?'}
</span>
</div>
)}
{/* Media status */}
{media && (
<section className="card">
<h3>Status</h3>
<div className="kv">
<span>Ready</span>
<span className={media.ready ? 'badge ok' : 'badge err'}>
{String(media.ready)}
</span>
</div>
<div className="kv">
<span>Playing</span>
<span className={media.playing ? 'badge active' : 'badge'}>
{String(media.playing)}
</span>
</div>
<div className="kv">
<span>Recording</span>
<span className={media.recording ? 'badge active' : 'badge'}>
{String(media.recording)}
</span>
</div>
{media.recording && (
<div className="kv">
<span>Elapsed</span>
<span>
{media.record_elapsed_seconds}s / {media.record_limit_seconds}s
</span>
</div>
)}
{media.record_simulated && (
<div className="notice info">
Recording is simulated (placeholder WAV).
</div>
)}
{media.last_error && (
<div className="notice err">
Last error: {media.last_error}
</div>
)}
</section>
)}
{/* File listings */}
{CATEGORIES.map((kind) => {
const list = listings[kind];
return (
<section className="card" key={kind}>
<h3>{kind.charAt(0).toUpperCase() + kind.slice(1)}</h3>
{!list ? (
<p className="muted">
Not loaded.{' '}
<button
type="button"
onClick={() =>
mediaFiles(kind).then((r) =>
setListings((prev) => ({ ...prev, [kind]: r })),
)
}
>
Load
</button>
</p>
) : list.files.length === 0 ? (
<p className="muted">No files.</p>
) : (
<ul className="file-list">
{list.files.map((f) => (
<li key={f}>
<span className="mono">{f}</span>
{kind === 'music' && (
<button
type="button"
disabled={busy}
onClick={() => handlePlay(f)}
>
Play
</button>
)}
</li>
))}
</ul>
)}
</section>
);
})}
{/* Playback controls */}
<section className="card">
<h3>Playback</h3>
<div className="actions">
<button
type="button"
disabled={busy || !media?.playing}
onClick={handleStop}
>
Stop
</button>
<button type="button" disabled={busy} onClick={loadFiles}>
Refresh files
</button>
</div>
</section>
{/* Recording */}
<section className="card">
<h3>Recording</h3>
<div className="rec-form">
<label>
Duration (s)
<input
type="number"
min={1}
max={120}
value={recSeconds}
onChange={(e) => setRecSeconds(Number(e.target.value))}
/>
</label>
<label>
Filename
<input
type="text"
value={recFilename}
onChange={(e) => setRecFilename(e.target.value)}
/>
</label>
</div>
<div className="actions">
<button
type="button"
disabled={busy || !!media?.recording}
onClick={handleRecordStart}
>
Record
</button>
<button
type="button"
disabled={busy || !media?.recording}
onClick={handleRecordStop}
>
Stop rec
</button>
</div>
</section>
{/* Boot mode */}
<section className="card">
<h3>Boot mode</h3>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => run('BootStatus', () => legacyControl('BOOT_MODE_STATUS'))}
>
Status
</button>
<button
type="button"
disabled={busy}
onClick={() =>
run('SetMedia', () =>
legacyControl('BOOT_MODE_SET MEDIA_MANAGER'),
)
}
>
Set Media
</button>
<button
type="button"
disabled={busy}
onClick={() =>
run('SetStory', () => legacyControl('BOOT_MODE_SET STORY'))
}
>
Set Story
</button>
<button
type="button"
disabled={busy}
onClick={() => run('Clear', () => legacyControl('BOOT_MODE_CLEAR'))}
>
Clear
</button>
</div>
</section>
{feedback && <p className="feedback">{feedback}</p>}
</div>
);
}
@@ -1,75 +0,0 @@
import { useState } from 'react';
import { networkReconnect, espnowOn, espnowOff } from '../lib/api';
import type { RuntimeState } from '../lib/useRuntimeStore';
type Props = { runtime: RuntimeState };
export function NetworkPanel({ runtime }: Props) {
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState('');
const net = runtime.legacy?.network;
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(true);
setFeedback('');
try {
await fn();
setFeedback(`${label} OK`);
} catch (err) {
setFeedback(
`${label} failed: ${err instanceof Error ? err.message : err}`,
);
} finally {
setBusy(false);
}
};
return (
<div className="network-panel">
<h2>Network</h2>
<section className="card">
<h3>WiFi</h3>
<div className="kv">
<span>State</span>
<span className="badge">{net?.state ?? '?'}</span>
</div>
<div className="kv">
<span>IP</span>
<span className="mono">{net?.ip ?? '?'}</span>
</div>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => run('Reconnect', networkReconnect)}
>
Reconnect WiFi
</button>
</div>
</section>
<section className="card">
<h3>ESP-NOW</h3>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => run('ESP-NOW On', espnowOn)}
>
Enable
</button>
<button
type="button"
disabled={busy}
onClick={() => run('ESP-NOW Off', espnowOff)}
>
Disable
</button>
</div>
</section>
{feedback && <p className="feedback">{feedback}</p>}
</div>
);
}
@@ -1,141 +0,0 @@
import { useState } from 'react';
const DEFAULT_BASE_URL =
(import.meta.env.VITE_STORY_API_BASE as string | undefined) ??
'http://localhost:8080';
type RuntimeControlsProps = {
yaml: string;
};
type ResponseView = {
label: string;
payload: string;
};
async function readResponsePayload(response: Response): Promise<string> {
const text = await response.text();
if (!text) {
return '<empty response>';
}
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
export function RuntimeControls({ yaml }: RuntimeControlsProps) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [result, setResult] = useState<ResponseView>({
label: 'No request yet',
payload: '',
});
const [busy, setBusy] = useState(false);
const runRequest = async (
label: string,
path: string,
init?: RequestInit,
) => {
setBusy(true);
try {
const response = await fetch(`${baseUrl}${path}`, init);
const payload = await readResponsePayload(response);
setResult({
label: `${label} -> HTTP ${response.status}`,
payload,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setResult({
label: `${label} -> FAILED`,
payload: message,
});
} finally {
setBusy(false);
}
};
return (
<div className="runtime-panel">
<div>
<div className="toolbar">
<input
type="text"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value.trim())}
aria-label="story api base url"
placeholder="http://esp32:8080"
/>
</div>
<div className="actions">
<button
type="button"
disabled={busy}
onClick={() => runRequest('list', '/api/story/list')}
>
List
</button>
<button
type="button"
disabled={busy}
onClick={() => runRequest('status', '/api/story/status')}
>
Status
</button>
<button
type="button"
disabled={busy}
onClick={() => runRequest('runtime3 status', '/api/runtime3/status')}
>
Runtime 3 Status
</button>
<button
type="button"
disabled={busy}
onClick={() => runRequest('runtime3 document', '/api/runtime3/document')}
>
Runtime 3 Document
</button>
<button
type="button"
disabled={busy || yaml.trim().length === 0}
onClick={() =>
runRequest('validate', '/api/story/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ yaml }),
})
}
>
Validate
</button>
<button
type="button"
disabled={busy || yaml.trim().length === 0}
onClick={() =>
runRequest('deploy', '/api/story/deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/x-yaml',
},
body: yaml,
})
}
>
Deploy
</button>
</div>
</div>
<pre aria-live="polite">
{result.label}
{'\n'}
{result.payload}
</pre>
</div>
);
}
@@ -1,202 +0,0 @@
import { useEffect, useRef, useState, useCallback, lazy, Suspense } from 'react';
import * as Blockly from 'blockly';
import 'blockly/blocks';
import { ensureScenarioBlocks } from './blocks/scene';
import { registerPuzzleBlocks } from './blocks/puzzle';
import { registerNpcBlocks } from './blocks/npc';
import { registerHardwareBlocks } from './blocks/hardware';
import { registerDeployBlocks } from './blocks/deploy';
import { SCENARIO_TOOLBOX } from './toolbox';
import {
buildScenarioGraph,
scenarioGraphToFirmwareYaml,
scenarioGraphToDisplayYaml,
} from './generators/yaml';
import { downloadYaml } from './export/download';
import { encodeScenarioToUrl } from './export/share';
import { validateScenarioGraph, formatValidationSummary } from './validators/workspace';
const LazyMonacoEditor = lazy(() => import('@monaco-editor/react'));
type YamlMode = 'display' | 'firmware';
export function ScenarioEditor() {
const hostRef = useRef<HTMLDivElement | null>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [scenarioName, setScenarioName] = useState('new_scenario');
const [yamlOutput, setYamlOutput] = useState('');
const [yamlMode, setYamlMode] = useState<YamlMode>('display');
const [sceneCount, setSceneCount] = useState(0);
const [validationSummary, setValidationSummary] = useState('');
const [validationErrors, setValidationErrors] = useState(0);
const regenerateYaml = useCallback(
(workspace: Blockly.WorkspaceSvg, mode: YamlMode) => {
const graph = buildScenarioGraph(workspace);
setSceneCount(graph.scenes.length);
const issues = validateScenarioGraph(graph);
setValidationSummary(formatValidationSummary(graph, issues));
setValidationErrors(issues.filter((i) => i.severity === 'error').length);
const yaml =
mode === 'firmware'
? scenarioGraphToFirmwareYaml(graph)
: scenarioGraphToDisplayYaml(graph);
setYamlOutput(yaml);
},
[],
);
useEffect(() => {
if (!hostRef.current) return;
ensureScenarioBlocks();
registerPuzzleBlocks();
registerNpcBlocks();
registerHardwareBlocks();
registerDeployBlocks();
const workspace = Blockly.inject(hostRef.current, {
toolbox: SCENARIO_TOOLBOX,
trashcan: true,
grid: { spacing: 20, length: 3, colour: '#3a3f52', snap: true },
zoom: { controls: true, wheel: true, startScale: 0.95 },
});
workspaceRef.current = workspace;
// Add a starter scene block
const block = workspace.newBlock('scenario_scene');
block.setFieldValue('SCENE_INTRO', 'NAME');
block.setFieldValue('Introduction scene', 'DESCRIPTION');
block.initSvg();
block.render();
block.moveBy(40, 40);
const onChange = (event: Blockly.Events.Abstract) => {
// Filter out UI-only events (viewport, click, etc.)
if (event.isUiEvent) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
regenerateYaml(workspace, yamlMode);
}, 150);
};
workspace.addChangeListener(onChange);
// Initial generation
regenerateYaml(workspace, yamlMode);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
workspace.removeChangeListener(onChange);
workspace.dispose();
workspaceRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-generate when mode changes
useEffect(() => {
if (workspaceRef.current) {
regenerateYaml(workspaceRef.current, yamlMode);
}
}, [yamlMode, regenerateYaml]);
const handleExportDisplay = useCallback(() => {
const graph = workspaceRef.current ? buildScenarioGraph(workspaceRef.current) : null;
if (!graph) return;
const yaml = scenarioGraphToDisplayYaml(graph);
downloadYaml(yaml, `${scenarioName}.yaml`);
}, [scenarioName]);
const handleExportFirmware = useCallback(() => {
const graph = workspaceRef.current ? buildScenarioGraph(workspaceRef.current) : null;
if (!graph) return;
const yaml = scenarioGraphToFirmwareYaml(graph);
downloadYaml(yaml, `${scenarioName}_firmware.yaml`);
}, [scenarioName]);
const handleShare = useCallback(() => {
if (!workspaceRef.current) return;
const xml = Blockly.Xml.domToText(
Blockly.Xml.workspaceToDom(workspaceRef.current),
);
const url = encodeScenarioToUrl(xml);
navigator.clipboard.writeText(url).then(() => {
// Visual feedback could be added here
});
}, []);
return (
<div className="scenario-editor">
<div className="scenario-editor-toolbar">
<input
type="text"
value={scenarioName}
onChange={(e) => setScenarioName(e.target.value)}
aria-label="scenario name"
placeholder="scenario name"
/>
<select
value={yamlMode}
onChange={(e) => setYamlMode(e.target.value as YamlMode)}
aria-label="YAML mode"
>
<option value="display">Display YAML</option>
<option value="firmware">Firmware YAML</option>
</select>
<button type="button" onClick={handleExportDisplay}>
Export YAML
</button>
<button type="button" onClick={handleExportFirmware}>
Export Firmware
</button>
<button type="button" onClick={handleShare}>
Share
</button>
<span className="scenario-editor-status">
{sceneCount} scene(s)
</span>
</div>
<div className="scenario-editor-split">
<div ref={hostRef} className="scenario-editor-blockly" />
<div className="scenario-editor-preview">
<Suspense
fallback={
<div style={{ padding: '1rem', color: 'var(--text-muted)' }}>
Loading editor...
</div>
}
>
<LazyMonacoEditor
height="100%"
defaultLanguage="yaml"
value={yamlOutput}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
readOnly: true,
wordWrap: 'on',
}}
theme="vs-dark"
/>
</Suspense>
</div>
</div>
<div
style={{
height: 24,
padding: '0 12px',
display: 'flex',
alignItems: 'center',
fontSize: 12,
borderTop: '1px solid #333',
color: validationErrors > 0 ? '#ff6b6b' : '#888',
backgroundColor: '#1a1a2e',
}}
data-testid="validation-status"
>
{validationSummary}
</div>
</div>
);
}
@@ -1,56 +0,0 @@
import * as Blockly from 'blockly';
let registered = false;
export function registerDeployBlocks(): void {
if (registered) return;
Blockly.defineBlocksWithJsonArray([
{
type: 'deploy_config_wifi',
message0: 'WiFi SSID %1 password %2',
args0: [
{ type: 'field_input', name: 'SSID', text: '' },
{ type: 'field_input', name: 'PASSWORD', text: '' },
],
colour: 0,
tooltip: 'Configure WiFi credentials for ESP32 deployment',
},
{
type: 'deploy_config_tts',
message0: 'TTS server %1 voice %2',
args0: [
{ type: 'field_input', name: 'URL', text: 'http://192.168.0.120:8001' },
{
type: 'field_dropdown',
name: 'VOICE',
options: [
['tom-medium', 'tom-medium'],
['siwis', 'siwis'],
['upmc', 'upmc'],
],
},
],
colour: 0,
tooltip: 'Configure Piper TTS server URL and voice for deployment',
},
{
type: 'deploy_config_llm',
message0: 'LLM server %1 model %2',
args0: [
{ type: 'field_input', name: 'URL', text: 'http://kxkm-ai:11434' },
{ type: 'field_input', name: 'MODEL', text: 'devstral' },
],
colour: 0,
tooltip: 'Configure LLM server URL and model for deployment',
},
{
type: 'deploy_export',
message0: 'Export pour ESP32',
colour: 0,
tooltip: 'Export the full scenario configuration for ESP32 firmware flashing',
},
]);
registered = true;
}
@@ -1,95 +0,0 @@
import * as Blockly from 'blockly';
let registered = false;
export function registerHardwareBlocks(): void {
if (registered) return;
Blockly.defineBlocksWithJsonArray([
{
type: 'hw_gpio_write',
message0: 'GPIO write pin %1 state %2',
args0: [
{ type: 'field_number', name: 'PIN', value: 4, min: 0, max: 48, precision: 1 },
{
type: 'field_dropdown',
name: 'STATE',
options: [
['HIGH', 'HIGH'],
['LOW', 'LOW'],
],
},
],
previousStatement: null,
nextStatement: null,
colour: 30,
tooltip: 'Write a digital value to a GPIO pin',
},
{
type: 'hw_gpio_read',
message0: 'GPIO read pin %1 into %2',
args0: [
{ type: 'field_number', name: 'PIN', value: 4, min: 0, max: 48, precision: 1 },
{ type: 'field_input', name: 'VARIABLE', text: 'pin_value' },
],
previousStatement: null,
nextStatement: null,
colour: 30,
tooltip: 'Read a digital value from a GPIO pin into a variable',
},
{
type: 'hw_led_set',
message0: 'LED color %1 animation %2',
args0: [
{ type: 'field_input', name: 'COLOR', text: '#00FF00' },
{
type: 'field_dropdown',
name: 'ANIMATION',
options: [
['solid', 'solid'],
['blink', 'blink'],
['pulse', 'pulse'],
['rainbow', 'rainbow'],
],
},
],
previousStatement: null,
nextStatement: null,
colour: 30,
tooltip: 'Set LED color and animation pattern',
},
{
type: 'hw_buzzer_tone',
message0: 'Buzzer %1 Hz for %2 ms',
args0: [
{ type: 'field_number', name: 'FREQUENCY', value: 440, min: 100, max: 5000, precision: 1 },
{ type: 'field_number', name: 'DURATION_MS', value: 500, min: 0, precision: 1 },
],
previousStatement: null,
nextStatement: null,
colour: 30,
tooltip: 'Play a tone on the buzzer at a given frequency and duration',
},
{
type: 'hw_play_audio',
message0: 'Play audio %1',
args0: [
{ type: 'field_input', name: 'FILENAME', text: 'audio.mp3' },
],
previousStatement: null,
nextStatement: null,
colour: 30,
tooltip: 'Play an audio file from the SD card',
},
{
type: 'hw_qr_scan',
message0: 'QR scan',
previousStatement: null,
nextStatement: null,
colour: 30,
tooltip: 'Activate the QR code scanner and wait for a scan result',
},
]);
registered = true;
}
@@ -1,94 +0,0 @@
import * as Blockly from 'blockly';
let registered = false;
const MOOD_OPTIONS: Array<[string, string]> = [
['neutral', 'neutral'],
['impressed', 'impressed'],
['worried', 'worried'],
['amused', 'amused'],
];
export function registerNpcBlocks(): void {
if (registered) return;
Blockly.defineBlocksWithJsonArray([
{
type: 'npc_say',
message0: 'NPC says %1 mood %2',
args0: [
{ type: 'field_input', name: 'TEXT', text: '' },
{
type: 'field_dropdown',
name: 'MOOD',
options: MOOD_OPTIONS,
},
],
previousStatement: null,
nextStatement: null,
colour: 160,
tooltip: 'Professor Zacus says something with a mood',
},
{
type: 'npc_mood_set',
message0: 'set NPC mood %1',
args0: [
{
type: 'field_dropdown',
name: 'MOOD',
options: MOOD_OPTIONS,
},
],
previousStatement: null,
nextStatement: null,
colour: 160,
tooltip: 'Change the NPC mood state',
},
{
type: 'npc_hint',
message0: 'hint level %1 for puzzle %2 text %3',
args0: [
{
type: 'field_dropdown',
name: 'LEVEL',
options: [
['1', '1'],
['2', '2'],
['3', '3'],
],
},
{ type: 'field_input', name: 'PUZZLE_ID', text: '' },
{ type: 'field_input', name: 'TEXT', text: '' },
],
previousStatement: null,
nextStatement: null,
colour: 160,
tooltip: 'A hint for a specific puzzle at a given difficulty level',
},
{
type: 'npc_react',
message0: 'NPC reacts to %1 with %2',
args0: [
{ type: 'input_value', name: 'CONDITION' },
{ type: 'field_input', name: 'RESPONSE', text: '' },
],
previousStatement: null,
nextStatement: null,
colour: 160,
tooltip: 'NPC reacts when a condition is met',
},
{
type: 'npc_conversation',
message0: 'conversation prompt %1 context %2',
args0: [
{ type: 'field_input', name: 'SYSTEM_PROMPT', text: '' },
{ type: 'field_input', name: 'CONTEXT', text: '' },
],
output: null,
colour: 160,
tooltip: 'An LLM-powered NPC conversation with system prompt and context',
},
]);
registered = true;
}
@@ -1,74 +0,0 @@
import * as Blockly from 'blockly';
let registered = false;
export function registerPuzzleBlocks(): void {
if (registered) return;
Blockly.defineBlocksWithJsonArray([
{
type: 'puzzle_definition',
message0: 'Puzzle %1 type %2',
args0: [
{ type: 'field_input', name: 'NAME', text: 'PUZZLE_NEW' },
{
type: 'field_dropdown',
name: 'PUZZLE_TYPE',
options: [
['QR', 'qr'],
['Button', 'button'],
['Sequence', 'sequence'],
['Free', 'free'],
],
},
],
message1: 'solution %1',
args1: [{ type: 'input_value', name: 'SOLUTION' }],
message2: 'hints %1',
args2: [{ type: 'input_statement', name: 'HINTS' }],
colour: 210,
tooltip: 'Define a puzzle with a type, solution, and hints',
},
{
type: 'puzzle_condition',
message0: '%1 %2',
args0: [
{
type: 'field_dropdown',
name: 'CONDITION_TYPE',
options: [
['puzzle solved', 'puzzle_solved'],
['timer expired', 'timer_expired'],
['variable equals', 'variable_equals'],
],
},
{ type: 'field_input', name: 'REFERENCE', text: '' },
],
output: 'Boolean',
colour: 210,
tooltip: 'A condition that evaluates to true/false',
},
{
type: 'puzzle_validation_qr',
message0: 'QR expected %1',
args0: [
{ type: 'field_input', name: 'EXPECTED', text: 'ZACUS_KEY_1' },
],
output: null,
colour: 210,
tooltip: 'QR code validation — matches scanned value against expected',
},
{
type: 'puzzle_validation_button',
message0: 'Button pin %1',
args0: [
{ type: 'field_number', name: 'PIN', value: 4, min: 0, precision: 1 },
],
output: null,
colour: 210,
tooltip: 'Button press validation on a specific GPIO pin',
},
]);
registered = true;
}
@@ -1,77 +0,0 @@
import * as Blockly from 'blockly';
let registered = false;
export function ensureScenarioBlocks(): void {
if (registered) return;
Blockly.defineBlocksWithJsonArray([
{
type: 'scenario_scene',
message0: 'Scene %1 description %2',
args0: [
{ type: 'field_input', name: 'NAME', text: 'SCENE_NEW' },
{ type: 'field_input', name: 'DESCRIPTION', text: '' },
],
message1: 'duration max %1 s',
args1: [
{ type: 'field_number', name: 'DURATION_MAX', value: 300, min: 0, precision: 1 },
],
message2: 'actions %1',
args2: [{ type: 'input_statement', name: 'ACTIONS' }],
message3: 'transitions %1',
args3: [{ type: 'input_statement', name: 'TRANSITIONS' }],
colour: 270,
tooltip: 'A scenario scene with actions and transitions',
},
{
type: 'scenario_transition',
message0: 'go to %1 when %2',
args0: [
{ type: 'field_input', name: 'TARGET_SCENE', text: 'SCENE_NEXT' },
{ type: 'input_value', name: 'CONDITION' },
],
previousStatement: null,
nextStatement: null,
colour: 160,
tooltip: 'Transition to another scene when condition is met',
},
{
type: 'scenario_timer',
message0: 'timer %1 s',
args0: [
{ type: 'field_number', name: 'SECONDS', value: 10, min: 0, precision: 1 },
],
message1: 'on expire %1',
args1: [{ type: 'input_statement', name: 'ON_EXPIRE' }],
previousStatement: null,
nextStatement: null,
colour: 60,
tooltip: 'Wait for a duration then execute actions',
},
{
type: 'scenario_variable_set',
message0: 'set %1 to %2',
args0: [
{ type: 'field_input', name: 'NAME', text: 'my_var' },
{ type: 'input_value', name: 'VALUE' },
],
previousStatement: null,
nextStatement: null,
colour: 330,
tooltip: 'Set a scenario variable',
},
{
type: 'scenario_variable_get',
message0: 'get %1',
args0: [
{ type: 'field_input', name: 'NAME', text: 'my_var' },
],
output: null,
colour: 330,
tooltip: 'Get a scenario variable value',
},
]);
registered = true;
}
@@ -1,19 +0,0 @@
export function downloadYaml(yaml: string, filename: string): void {
const blob = new Blob([yaml], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function downloadJson(data: object, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
@@ -1,14 +0,0 @@
export function encodeScenarioToUrl(workspaceXml: string): string {
const compressed = btoa(encodeURIComponent(workspaceXml));
return `${window.location.origin}${window.location.pathname}#scenario=${compressed}`;
}
export function decodeScenarioFromUrl(hash: string): string | null {
const match = hash.match(/#scenario=(.+)/);
if (!match) return null;
try {
return decodeURIComponent(atob(match[1]));
} catch {
return null;
}
}
@@ -1,449 +0,0 @@
import * as Blockly from 'blockly';
import YAML from 'yaml';
import type {
ScenarioGraph,
SceneNode,
TransitionEdge,
SceneAction,
TimerAction,
VariableSetAction,
PuzzleNode,
NPCAction,
HardwareAction,
DeployConfig,
} from '../types';
// ─── Workspace → ScenarioGraph ───
function readConditionText(block: Blockly.Block, inputName: string): string {
const target = block.getInputTargetBlock(inputName);
if (!target) return '';
if (target.type === 'scenario_variable_get') {
return `var:${target.getFieldValue('NAME') ?? 'unknown'}`;
}
return target.type;
}
function readActions(block: Blockly.Block, inputName: string): SceneAction[] {
const actions: SceneAction[] = [];
let cursor = block.getInputTargetBlock(inputName);
while (cursor) {
if (cursor.type === 'scenario_timer') {
const timerAction: TimerAction = {
kind: 'timer',
seconds: Number(cursor.getFieldValue('SECONDS') ?? 10),
onExpire: readActions(cursor, 'ON_EXPIRE'),
};
actions.push(timerAction);
} else if (cursor.type === 'scenario_variable_set') {
const varAction: VariableSetAction = {
kind: 'variable_set',
name: String(cursor.getFieldValue('NAME') ?? 'my_var'),
value: readConditionText(cursor, 'VALUE'),
};
actions.push(varAction);
}
cursor = cursor.getNextBlock();
}
return actions;
}
function readTransitions(block: Blockly.Block): TransitionEdge[] {
const transitions: TransitionEdge[] = [];
let cursor = block.getInputTargetBlock('TRANSITIONS');
while (cursor) {
if (cursor.type === 'scenario_transition') {
transitions.push({
targetScene: String(cursor.getFieldValue('TARGET_SCENE') ?? 'SCENE_NEXT'),
condition: readConditionText(cursor, 'CONDITION'),
});
}
cursor = cursor.getNextBlock();
}
return transitions;
}
// ─── Puzzle blocks → PuzzleNode ───
function readPuzzleHints(block: Blockly.Block): Array<{ level: number; text: string }> {
const hints: Array<{ level: number; text: string }> = [];
let cursor = block.getInputTargetBlock('HINTS');
while (cursor) {
if (cursor.type === 'npc_hint') {
hints.push({
level: Number(cursor.getFieldValue('LEVEL') ?? 1),
text: String(cursor.getFieldValue('TEXT') ?? ''),
});
}
cursor = cursor.getNextBlock();
}
return hints;
}
function readPuzzleSolution(block: Blockly.Block): string | undefined {
const target = block.getInputTargetBlock('SOLUTION');
if (!target) return undefined;
if (target.type === 'puzzle_validation_qr') {
return `qr:${target.getFieldValue('EXPECTED') ?? ''}`;
}
if (target.type === 'puzzle_validation_button') {
return `button:${target.getFieldValue('PIN') ?? 4}`;
}
return target.type;
}
// ─── NPC blocks → NPCAction[] ───
function readNpcActions(workspace: Blockly.Workspace): NPCAction[] {
const actions: NPCAction[] = [];
for (const block of workspace.getAllBlocks(true)) {
switch (block.type) {
case 'npc_say':
actions.push({
type: 'say',
text: String(block.getFieldValue('TEXT') ?? ''),
mood: String(block.getFieldValue('MOOD') ?? 'neutral'),
});
break;
case 'npc_mood_set':
actions.push({
type: 'mood',
mood: String(block.getFieldValue('MOOD') ?? 'neutral'),
});
break;
case 'npc_hint':
// Only collect standalone hints (not those inside puzzle_definition)
if (!block.getParent() || block.getParent()?.type !== 'puzzle_definition') {
actions.push({
type: 'hint',
level: Number(block.getFieldValue('LEVEL') ?? 1),
text: String(block.getFieldValue('TEXT') ?? ''),
puzzleId: String(block.getFieldValue('PUZZLE_ID') ?? ''),
});
}
break;
case 'npc_react':
actions.push({
type: 'react',
condition: readConditionText(block, 'CONDITION'),
text: String(block.getFieldValue('RESPONSE') ?? ''),
});
break;
case 'npc_conversation':
actions.push({
type: 'conversation',
systemPrompt: String(block.getFieldValue('SYSTEM_PROMPT') ?? ''),
text: String(block.getFieldValue('CONTEXT') ?? ''),
});
break;
}
}
return actions;
}
// ─── Hardware blocks → HardwareAction[] ───
function readHardwareActions(workspace: Blockly.Workspace): HardwareAction[] {
const actions: HardwareAction[] = [];
for (const block of workspace.getAllBlocks(true)) {
switch (block.type) {
case 'hw_gpio_write':
actions.push({
type: 'gpio_write',
pin: Number(block.getFieldValue('PIN') ?? 4),
state: String(block.getFieldValue('STATE') ?? 'HIGH') as 'HIGH' | 'LOW',
});
break;
case 'hw_gpio_read':
actions.push({
type: 'gpio_read',
pin: Number(block.getFieldValue('PIN') ?? 4),
variable: String(block.getFieldValue('VARIABLE') ?? 'pin_value'),
});
break;
case 'hw_led_set':
actions.push({
type: 'led_set',
color: String(block.getFieldValue('COLOR') ?? '#00FF00'),
animation: String(block.getFieldValue('ANIMATION') ?? 'solid'),
});
break;
case 'hw_buzzer_tone':
actions.push({
type: 'buzzer',
frequency: Number(block.getFieldValue('FREQUENCY') ?? 440),
duration_ms: Number(block.getFieldValue('DURATION_MS') ?? 500),
});
break;
case 'hw_play_audio':
actions.push({
type: 'play_audio',
filename: String(block.getFieldValue('FILENAME') ?? 'audio.mp3'),
});
break;
case 'hw_qr_scan':
actions.push({ type: 'qr_scan' });
break;
}
}
return actions;
}
// ─── Deploy blocks → DeployConfig ───
function readDeployConfig(workspace: Blockly.Workspace): DeployConfig {
const config: DeployConfig = {};
for (const block of workspace.getAllBlocks(true)) {
switch (block.type) {
case 'deploy_config_wifi':
config.wifi = {
ssid: String(block.getFieldValue('SSID') ?? ''),
password: String(block.getFieldValue('PASSWORD') ?? ''),
};
break;
case 'deploy_config_tts':
config.tts = {
url: String(block.getFieldValue('URL') ?? 'http://192.168.0.120:8001'),
voice: String(block.getFieldValue('VOICE') ?? 'tom-medium'),
};
break;
case 'deploy_config_llm':
config.llm = {
url: String(block.getFieldValue('URL') ?? 'http://kxkm-ai:11434'),
model: String(block.getFieldValue('MODEL') ?? 'devstral'),
};
break;
}
}
return config;
}
/**
* Walk workspace top blocks and build a ScenarioGraph.
* Works with both WorkspaceSvg and headless Workspace.
*/
export function buildScenarioGraph(workspace: Blockly.Workspace): ScenarioGraph {
const scenes: SceneNode[] = [];
const puzzles: PuzzleNode[] = [];
const seen = new Set<string>();
for (const block of workspace.getTopBlocks(true)) {
if (block.type === 'scenario_scene') {
const id = block.id;
if (seen.has(id)) continue;
seen.add(id);
scenes.push({
name: String(block.getFieldValue('NAME') ?? 'SCENE_NEW'),
description: String(block.getFieldValue('DESCRIPTION') ?? ''),
durationMax: Number(block.getFieldValue('DURATION_MAX') ?? 300),
actions: readActions(block, 'ACTIONS'),
transitions: readTransitions(block),
});
} else if (block.type === 'puzzle_definition') {
const id = block.id;
if (seen.has(id)) continue;
seen.add(id);
puzzles.push({
id,
name: String(block.getFieldValue('NAME') ?? 'PUZZLE_NEW'),
type: String(block.getFieldValue('PUZZLE_TYPE') ?? 'free') as PuzzleNode['type'],
solution: readPuzzleSolution(block),
hints: readPuzzleHints(block),
});
}
}
const npcActions = readNpcActions(workspace);
const hardwareActions = readHardwareActions(workspace);
const deploy = readDeployConfig(workspace);
return { scenes, puzzles, npcActions, hardwareActions, deploy };
}
// ─── ScenarioGraph → Firmware YAML ───
function actionsToFirmwareTransitions(
actions: SceneAction[],
sceneIndex: number,
): Array<{
event_type: string;
event_name: string;
target_step_id: string;
priority: number;
after_ms: number;
}> {
const result: Array<{
event_type: string;
event_name: string;
target_step_id: string;
priority: number;
after_ms: number;
}> = [];
for (const action of actions) {
if (action.kind === 'timer') {
// A timer with on_expire transitions generates a timer-type firmware transition
for (const child of action.onExpire) {
if (child.kind === 'variable_set') {
// Variable sets in on_expire map to action-type transitions
result.push({
event_type: 'action',
event_name: `SET_${child.name.toUpperCase()}`,
target_step_id: '',
priority: 0,
after_ms: action.seconds * 1000,
});
}
}
}
}
void sceneIndex;
return result;
}
/**
* Project ScenarioGraph to firmware.steps[] format compatible with compile_runtime3.py
*/
export function scenarioGraphToFirmwareYaml(graph: ScenarioGraph): string {
const steps = graph.scenes.map((scene, index) => {
const stepId = scene.name.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
// Build transitions from scene transitions + timer actions
const transitions = scene.transitions.map((t, ti) => ({
event_type: t.condition ? 'serial' : 'button',
event_name: t.condition || `TR_${ti + 1}`,
target_step_id: t.targetScene.toUpperCase().replace(/[^A-Z0-9_]/g, '_'),
priority: 0,
after_ms: 0,
}));
// Add timer-derived transitions
transitions.push(...actionsToFirmwareTransitions(scene.actions, index));
return {
step_id: stepId,
screen_scene_id: stepId,
audio_pack_id: '',
actions: [] as Record<string, unknown>[],
apps: [] as string[],
transitions,
};
});
// Map puzzles to firmware events
const puzzleEvents = graph.puzzles.map((p) => ({
puzzle_id: p.name.toUpperCase().replace(/[^A-Z0-9_]/g, '_'),
type: p.type,
solution: p.solution ?? '',
hints_count: p.hints.length,
}));
// Map hardware actions to firmware action entries
const hwActions = graph.hardwareActions.map((a) => {
const entry: Record<string, unknown> = { type: a.type };
if (a.pin !== undefined) entry.pin = a.pin;
if (a.state !== undefined) entry.state = a.state;
if (a.variable !== undefined) entry.variable = a.variable;
if (a.color !== undefined) entry.color = a.color;
if (a.animation !== undefined) entry.animation = a.animation;
if (a.frequency !== undefined) entry.frequency = a.frequency;
if (a.duration_ms !== undefined) entry.duration_ms = a.duration_ms;
if (a.filename !== undefined) entry.filename = a.filename;
return entry;
});
// Attach hardware actions to the first step (default assignment)
if (steps.length > 0 && hwActions.length > 0) {
steps[0].actions = hwActions;
}
const deploy = graph.deploy;
const hasDeployConfig =
deploy.wifi !== undefined || deploy.tts !== undefined || deploy.llm !== undefined;
const firmware: Record<string, unknown> = {
initial_step: steps[0]?.step_id ?? 'STEP_BOOT',
steps,
...(puzzleEvents.length > 0 ? { puzzles: puzzleEvents } : {}),
...(hasDeployConfig ? { deploy } : {}),
};
return YAML.stringify({ firmware });
}
// ─── ScenarioGraph → Display YAML ───
function actionToDisplay(action: SceneAction): Record<string, unknown> {
if (action.kind === 'timer') {
return {
type: 'timer',
seconds: action.seconds,
on_expire: action.onExpire.map(actionToDisplay),
};
}
return {
type: 'variable_set',
name: action.name,
value: action.value,
};
}
/**
* Project ScenarioGraph to a rich human-readable YAML format
*/
export function scenarioGraphToDisplayYaml(graph: ScenarioGraph): string {
const scenes = graph.scenes.map((scene) => ({
name: scene.name,
description: scene.description,
duration_max_s: scene.durationMax,
actions: scene.actions.map(actionToDisplay),
transitions: scene.transitions.map((t) => ({
target: t.targetScene,
condition: t.condition || undefined,
})),
}));
const puzzles = graph.puzzles.map((p) => ({
name: p.name,
type: p.type,
solution: p.solution ?? undefined,
hints: p.hints.map((h) => ({ level: h.level, text: h.text })),
}));
const npc = graph.npcActions.map((a) => {
const entry: Record<string, unknown> = { type: a.type };
if (a.text !== undefined) entry.text = a.text;
if (a.mood !== undefined) entry.mood = a.mood;
if (a.level !== undefined) entry.level = a.level;
if (a.puzzleId !== undefined) entry.puzzle_id = a.puzzleId;
if (a.condition) entry.condition = a.condition;
if (a.systemPrompt) entry.system_prompt = a.systemPrompt;
return entry;
});
const hardware = graph.hardwareActions.map((a) => {
const entry: Record<string, unknown> = { type: a.type };
if (a.pin !== undefined) entry.pin = a.pin;
if (a.state !== undefined) entry.state = a.state;
if (a.variable !== undefined) entry.variable = a.variable;
if (a.color !== undefined) entry.color = a.color;
if (a.animation !== undefined) entry.animation = a.animation;
if (a.frequency !== undefined) entry.frequency = a.frequency;
if (a.duration_ms !== undefined) entry.duration_ms = a.duration_ms;
if (a.filename !== undefined) entry.filename = a.filename;
return entry;
});
const deploy = graph.deploy;
const hasDeployConfig =
deploy.wifi !== undefined || deploy.tts !== undefined || deploy.llm !== undefined;
const doc: Record<string, unknown> = { scenes };
if (puzzles.length > 0) doc.puzzles = puzzles;
if (npc.length > 0) doc.npc = npc;
if (hardware.length > 0) doc.hardware = hardware;
if (hasDeployConfig) doc.deploy = deploy;
return YAML.stringify(doc);
}
@@ -1 +0,0 @@
export { ScenarioEditor } from './ScenarioEditor';
@@ -1,66 +0,0 @@
import type * as Blockly from 'blockly';
export const SCENARIO_TOOLBOX: Blockly.utils.toolbox.ToolboxInfo = {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'Scenario',
colour: '270',
contents: [
{ kind: 'block', type: 'scenario_scene' },
{ kind: 'block', type: 'scenario_transition' },
{ kind: 'block', type: 'scenario_timer' },
{ kind: 'block', type: 'scenario_variable_set' },
{ kind: 'block', type: 'scenario_variable_get' },
],
},
{
kind: 'category',
name: 'Puzzles',
colour: '210',
contents: [
{ kind: 'block', type: 'puzzle_definition' },
{ kind: 'block', type: 'puzzle_condition' },
{ kind: 'block', type: 'puzzle_validation_qr' },
{ kind: 'block', type: 'puzzle_validation_button' },
],
},
{
kind: 'category',
name: 'NPC / Dialogue',
colour: '160',
contents: [
{ kind: 'block', type: 'npc_say' },
{ kind: 'block', type: 'npc_mood_set' },
{ kind: 'block', type: 'npc_hint' },
{ kind: 'block', type: 'npc_react' },
{ kind: 'block', type: 'npc_conversation' },
],
},
{
kind: 'category',
name: 'Hardware',
colour: '30',
contents: [
{ kind: 'block', type: 'hw_gpio_write' },
{ kind: 'block', type: 'hw_gpio_read' },
{ kind: 'block', type: 'hw_led_set' },
{ kind: 'block', type: 'hw_buzzer_tone' },
{ kind: 'block', type: 'hw_play_audio' },
{ kind: 'block', type: 'hw_qr_scan' },
],
},
{
kind: 'category',
name: 'Deploy',
colour: '0',
contents: [
{ kind: 'block', type: 'deploy_config_wifi' },
{ kind: 'block', type: 'deploy_config_tts' },
{ kind: 'block', type: 'deploy_config_llm' },
{ kind: 'block', type: 'deploy_export' },
],
},
],
};
@@ -1,87 +0,0 @@
/** A scene in the scenario graph */
export interface SceneNode {
/** Unique scene identifier (e.g. SCENE_INTRO) */
name: string;
/** Human-readable description */
description: string;
/** Maximum duration in seconds (0 = unlimited) */
durationMax: number;
/** Actions attached to this scene (timers, variable sets, etc.) */
actions: SceneAction[];
/** Outgoing transitions */
transitions: TransitionEdge[];
}
/** A transition from one scene to another */
export interface TransitionEdge {
/** Target scene name */
targetScene: string;
/** Optional condition description (from connected value block) */
condition: string;
}
/** A timer action inside a scene */
export interface TimerAction {
kind: 'timer';
seconds: number;
/** Actions to execute on expiry (nested transitions, variable sets, etc.) */
onExpire: SceneAction[];
}
/** A variable set action */
export interface VariableSetAction {
kind: 'variable_set';
name: string;
value: string;
}
export type SceneAction = TimerAction | VariableSetAction;
/** A puzzle definition in the scenario */
export interface PuzzleNode {
id: string;
name: string;
type: 'qr' | 'button' | 'sequence' | 'free';
solution?: string;
hints: Array<{ level: number; text: string }>;
}
/** An NPC action (say, mood change, hint, react, conversation) */
export interface NPCAction {
type: 'say' | 'mood' | 'hint' | 'react' | 'conversation';
text?: string;
mood?: string;
level?: number;
puzzleId?: string;
condition?: string;
systemPrompt?: string;
}
/** A hardware action (GPIO, LED, buzzer, audio, QR) */
export interface HardwareAction {
type: 'gpio_write' | 'gpio_read' | 'led_set' | 'buzzer' | 'play_audio' | 'qr_scan';
pin?: number;
state?: 'HIGH' | 'LOW';
variable?: string;
color?: string;
animation?: string;
frequency?: number;
duration_ms?: number;
filename?: string;
}
/** Deploy configuration for ESP32 targets */
export interface DeployConfig {
wifi?: { ssid: string; password: string };
tts?: { url: string; voice: string };
llm?: { url: string; model: string };
}
/** The full scenario graph extracted from the Blockly workspace */
export interface ScenarioGraph {
scenes: SceneNode[];
puzzles: PuzzleNode[];
npcActions: NPCAction[];
hardwareActions: HardwareAction[];
deploy: DeployConfig;
}
@@ -1,88 +0,0 @@
import type { ScenarioGraph } from '../types';
export interface ValidationIssue {
severity: 'error' | 'warning';
message: string;
blockId?: string;
}
export function validateScenarioGraph(graph: ScenarioGraph): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// Check: at least one scene
if (graph.scenes.length === 0) {
issues.push({ severity: 'error', message: 'No scenes defined' });
}
// Check: duplicate scene names
const sceneNames = graph.scenes.map((s) => s.name);
const dupes = sceneNames.filter((name, i) => sceneNames.indexOf(name) !== i);
for (const d of new Set(dupes)) {
issues.push({ severity: 'error', message: `Duplicate scene ID: "${d}"` });
}
// Check: transitions point to existing scenes
for (const scene of graph.scenes) {
for (const t of scene.transitions) {
if (!sceneNames.includes(t.targetScene)) {
issues.push({
severity: 'error',
message: `Transition target "${t.targetScene}" not found`,
blockId: scene.name,
});
}
}
}
// Check: unreachable scenes (no incoming transitions, except first)
if (graph.scenes.length > 1) {
const targets = new Set(
graph.scenes.flatMap((s) => s.transitions.map((t) => t.targetScene)),
);
for (const scene of graph.scenes.slice(1)) {
if (!targets.has(scene.name)) {
issues.push({
severity: 'warning',
message: `Scene "${scene.name}" is unreachable (no incoming transitions)`,
blockId: scene.name,
});
}
}
}
// Check: puzzles without hints
for (const puzzle of graph.puzzles) {
if (puzzle.hints.length === 0) {
issues.push({
severity: 'warning',
message: `Puzzle "${puzzle.name}" has no hints`,
});
}
}
// Check: duplicate puzzle IDs
const puzzleIds = graph.puzzles.map((p) => p.id);
const puzzleDupes = puzzleIds.filter((id, i) => puzzleIds.indexOf(id) !== i);
for (const d of new Set(puzzleDupes)) {
issues.push({ severity: 'error', message: `Duplicate puzzle ID: "${d}"` });
}
return issues;
}
export function formatValidationSummary(
graph: ScenarioGraph,
issues: ValidationIssue[],
): string {
const errors = issues.filter((i) => i.severity === 'error').length;
const warnings = issues.filter((i) => i.severity === 'warning').length;
const stats = `${graph.scenes.length} scenes, ${graph.puzzles.length} puzzles, ${graph.npcActions.length} NPC lines`;
if (errors > 0) {
return `${stats} | ${errors} error${errors > 1 ? 's' : ''}, ${warnings} warning${warnings > 1 ? 's' : ''}`;
}
if (warnings > 0) {
return `${stats} | ${warnings} warning${warnings > 1 ? 's' : ''}`;
}
return `${stats} | Ready`;
}
-15
View File
@@ -1,15 +0,0 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
}
-407
View File
@@ -1,407 +0,0 @@
/**
* API client aligned with STORY_RUNTIME_API_JSON_CONTRACT.md
* Supports Story V2 + Legacy Freenove endpoints.
*/
const DEFAULT_BASE =
(import.meta.env.VITE_STORY_API_BASE as string | undefined) ??
'http://localhost:8080';
let baseUrl = DEFAULT_BASE;
export function setApiBase(url: string) {
baseUrl = url.replace(/\/+$/, '');
}
export function getApiBase() {
return baseUrl;
}
// ─── Helpers ───
async function jsonFetch<T>(path: string, init?: RequestInit, timeoutMs = 5000): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(`${baseUrl}${path}`, {
...init,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError(0, `Request timed out after ${timeoutMs}ms`, path);
}
throw err;
}
clearTimeout(timeoutId);
const text = await res.text();
if (!text) throw new ApiError(res.status, 'empty response', path);
const data = JSON.parse(text);
if (!res.ok) {
const msg = data?.error?.message ?? data?.error ?? res.statusText;
throw new ApiError(res.status, msg, path);
}
if (data?.ok === false) {
throw new ApiError(res.status, data.error ?? 'operation failed', path);
}
return data as T;
}
export class ApiError extends Error {
status: number;
path: string;
constructor(
status: number,
message: string,
path: string,
) {
super(message);
this.name = 'ApiError';
this.status = status;
this.path = path;
}
}
// ─── Story V2 ───
export interface ScenarioListItem {
id: string;
version: number;
estimated_duration_s: number;
}
export interface StoryStatus {
status: 'idle' | 'running' | 'paused';
scenario_id: string;
current_step: string;
progress_pct: number;
started_at_ms: number;
selected: string;
queue_depth: number;
}
export async function storyList(): Promise<ScenarioListItem[]> {
const data = await jsonFetch<{ scenarios: ScenarioListItem[] }>(
'/api/story/list',
);
return data.scenarios;
}
export async function storyStatus(): Promise<StoryStatus> {
return jsonFetch<StoryStatus>('/api/story/status');
}
export async function storySelect(scenarioId: string) {
return jsonFetch<{ selected: string; status: string }>(
`/api/story/select/${encodeURIComponent(scenarioId)}`,
{ method: 'POST' },
);
}
export async function storyStart() {
return jsonFetch<{ status: string; current_step: string }>(
'/api/story/start',
{ method: 'POST' },
);
}
export async function storyPause() {
return jsonFetch<{ status: string }>('/api/story/pause', { method: 'POST' });
}
export async function storyResume() {
return jsonFetch<{ status: string }>('/api/story/resume', { method: 'POST' });
}
export async function storySkip() {
return jsonFetch<{ previous_step: string; current_step: string }>(
'/api/story/skip',
{ method: 'POST' },
);
}
export async function storyValidate(yaml: string) {
return jsonFetch<{ valid: boolean }>('/api/story/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yaml }),
});
}
export async function storyDeploy(yaml: string) {
return jsonFetch<{ deployed: string; status: string }>('/api/story/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/x-yaml' },
body: yaml,
});
}
// ─── Legacy / Status ───
export interface LegacyStatus {
story: {
scenario: string;
step: string;
screen?: string;
audio_pack?: string;
runtime_contract?: string;
};
runtime3?: Runtime3FirmwareStatus;
network?: {
state?: string;
ip?: string;
};
media?: MediaStatus;
}
export interface Runtime3FirmwareStatus {
discovered: boolean;
loaded: boolean;
path: string;
schema_version: string;
scenario_id: string;
scenario_version: number;
entry_step_id: string;
source_kind: string;
generated_by: string;
migration_mode: string;
step_count: number;
transition_count: number;
size_bytes: number;
error: string;
}
export interface MediaStatus {
ready: boolean;
playing: boolean;
recording: boolean;
record_limit_seconds: number;
record_elapsed_seconds: number;
record_file: string;
record_simulated: boolean;
music_dir: string;
picture_dir: string;
record_dir: string;
last_ok: boolean;
last_error: string;
}
export async function legacyStatus(): Promise<LegacyStatus> {
return jsonFetch<LegacyStatus>('/api/status');
}
export async function runtime3Status(): Promise<Runtime3FirmwareStatus> {
return jsonFetch<Runtime3FirmwareStatus>('/api/runtime3/status');
}
export async function runtime3Document(): Promise<Record<string, unknown>> {
return jsonFetch<Record<string, unknown>>('/api/runtime3/document');
}
// ─── Media ───
export interface MediaFileList {
ok: boolean;
kind: string;
files: string[];
}
export async function mediaFiles(
kind: 'music' | 'picture' | 'recorder',
): Promise<MediaFileList> {
return jsonFetch<MediaFileList>(
`/api/media/files?kind=${encodeURIComponent(kind)}`,
);
}
export async function mediaPlay(path: string) {
return jsonFetch<{ action: string; ok: boolean }>('/api/media/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
}
export async function mediaStop() {
return jsonFetch<{ action: string; ok: boolean }>('/api/media/stop', {
method: 'POST',
});
}
export async function mediaRecordStart(seconds: number, filename: string) {
return jsonFetch<{ action: string; ok: boolean }>(
'/api/media/record/start',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seconds, filename }),
},
);
}
export async function mediaRecordStop() {
return jsonFetch<{ action: string; ok: boolean }>('/api/media/record/stop', {
method: 'POST',
});
}
// ─── Network ───
export async function networkReconnect() {
return jsonFetch<{ action: string; ok: boolean }>(
'/api/network/wifi/reconnect',
{ method: 'POST' },
);
}
export async function espnowOn() {
return jsonFetch<{ action: string; ok: boolean }>('/api/network/espnow/on', {
method: 'POST',
});
}
export async function espnowOff() {
return jsonFetch<{ action: string; ok: boolean }>('/api/network/espnow/off', {
method: 'POST',
});
}
// ─── Control (legacy fallback) ───
export async function legacyControl(action: string) {
return jsonFetch<{ ok: boolean; action: string }>('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
}
// ─── WebSocket Stream ───
export type StreamMessage = {
type: 'status' | 'step_change' | 'transition' | 'audit_log';
timestamp: number;
data: Record<string, unknown>;
};
export function connectStoryStream(
onMessage: (msg: StreamMessage) => void,
onError?: (err: Event) => void,
): { close: () => void } {
const MAX_BACKOFF_MS = 8000;
let attempt = 0;
let ws: WebSocket | null = null;
let closed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (closed) return;
const wsUrl = baseUrl.replace(/^http/, 'ws') + '/api/story/stream';
ws = new WebSocket(wsUrl);
ws.onopen = () => {
attempt = 0;
};
ws.onmessage = (ev) => {
try {
onMessage(JSON.parse(ev.data));
} catch {
/* ignore parse errors */
}
};
ws.onerror = (err) => {
if (onError) onError(err);
};
ws.onclose = () => {
if (closed) return;
const delay = Math.min(1000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
attempt++;
reconnectTimer = setTimeout(connect, delay);
};
}
connect();
return {
close() {
closed = true;
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
ws?.close();
},
};
}
// ─── SSE Stream (legacy) ───
export function connectLegacyStream(
onStatus: (data: LegacyStatus) => void,
): EventSource {
const es = new EventSource(`${baseUrl}/api/stream`);
es.addEventListener('status', (ev) => {
try {
onStatus(JSON.parse((ev as MessageEvent).data));
} catch {
/* ignore */
}
});
return es;
}
// ---------------------------------------------------------------------------
// Voice pipeline
// ---------------------------------------------------------------------------
export interface VoiceStatus {
connected: boolean;
state: number;
has_audio: boolean;
last_response?: string;
}
export async function voiceStatus(): Promise<VoiceStatus> {
return jsonFetch<VoiceStatus>(`${baseUrl}/api/voice/status`);
}
export async function voiceQuery(text: string): Promise<{ status: string }> {
return jsonFetch<{ status: string }>(`${baseUrl}/api/voice/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
}
// ─── Analytics ───
export interface GameAnalytics {
session_id: string;
duration_ms: number;
puzzles_solved: number;
total_hints: number;
total_attempts: number;
puzzles: Array<{
puzzle_id: string;
solved: boolean;
attempts: number;
hints: number;
duration_ms: number;
}>;
}
export async function gameAnalytics(): Promise<GameAnalytics> {
return jsonFetch<GameAnalytics>(`${baseUrl}/api/analytics`);
}
// ─── Hints (via mascarade) ───
export async function askHint(puzzleId: string, question: string, hintLevel: number): Promise<{ hint: string; hint_count: number }> {
return jsonFetch<{ hint: string; hint_count: number }>(`${baseUrl}/api/voice/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `[HINT:${puzzleId}:${hintLevel}] ${question}` }),
});
}
-172
View File
@@ -1,172 +0,0 @@
import type { ScenarioDocument, StepTransition } from '../types';
export type Runtime3EventType =
| 'button'
| 'serial'
| 'timer'
| 'audio_done'
| 'unlock'
| 'espnow'
| 'action';
export interface Runtime3Transition {
id: string;
event_type: Runtime3EventType;
event_name: string;
target_step_id: string;
priority: number;
after_ms: number;
}
export interface Runtime3Step {
id: string;
scene_id: string;
audio_pack_id: string;
actions: string[];
apps: string[];
transitions: Runtime3Transition[];
}
export interface Runtime3Document {
schema_version: 'zacus.runtime3.v1';
scenario: {
id: string;
version: number;
title: string;
entry_step_id: string;
source_kind: 'studio';
};
steps: Runtime3Step[];
metadata: {
generated_by: 'frontend-scratch-v2';
migration_mode: 'native';
};
}
function normalizeToken(raw: string, fallback: string): string {
const cleaned = raw
.trim()
.replace(/[^a-zA-Z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
return cleaned || fallback;
}
function normalizeEventType(raw: string): Runtime3EventType {
const allowed: Runtime3EventType[] = [
'button',
'serial',
'timer',
'audio_done',
'unlock',
'espnow',
'action',
];
return allowed.includes(raw as Runtime3EventType)
? (raw as Runtime3EventType)
: 'serial';
}
function normalizeTransition(
transition: Pick<StepTransition, 'eventName' | 'targetStepId' | 'priority' | 'afterMs'> & {
eventType: string;
},
index: number,
): Runtime3Transition {
return {
id: `TR_${index + 1}_${normalizeToken(transition.targetStepId, 'STEP_NEXT')}`,
event_type: normalizeEventType(transition.eventType),
event_name: normalizeToken(transition.eventName, 'BTN_NEXT'),
target_step_id: normalizeToken(transition.targetStepId, 'STEP_NEXT'),
priority: transition.priority ?? 0,
after_ms: transition.afterMs ?? 0,
};
}
export function compileScenarioDocumentToRuntime3(
document: ScenarioDocument,
): Runtime3Document {
const steps = (document.firmware?.steps ?? []).map((step, index) => ({
id: normalizeToken(step.step_id, `STEP_${index + 1}`),
scene_id: normalizeToken(step.screen_scene_id, `SCENE_${index + 1}`),
audio_pack_id: normalizeToken(step.audio_pack_id, ''),
actions: step.actions ?? [],
apps: step.apps ?? [],
transitions: (step.transitions ?? []).map((transition, transitionIndex) =>
normalizeTransition(
{
eventType: transition.event_type,
eventName: transition.event_name,
targetStepId: transition.target_step_id,
priority: transition.priority,
afterMs: transition.after_ms,
},
transitionIndex,
),
),
}));
const entryStepId =
normalizeToken(
document.firmware?.initial_step ??
document.firmware?.steps?.[0]?.step_id ??
document.firmware?.steps_reference_order?.[0] ??
document.steps_narrative[0]?.step_id ??
'',
'STEP_BOOT',
) || 'STEP_BOOT';
return {
schema_version: 'zacus.runtime3.v1',
scenario: {
id: normalizeToken(document.id, 'ZACUS_RUNTIME3'),
version: document.version,
title: document.title,
entry_step_id: entryStepId,
source_kind: 'studio',
},
steps,
metadata: {
generated_by: 'frontend-scratch-v2',
migration_mode: 'native',
},
};
}
export function validateRuntime3Document(
document: Runtime3Document,
): { ok: true } | { ok: false; error: string } {
const stepIds = new Set<string>();
if (document.steps.length === 0) {
return { ok: false, error: 'runtime3 requires at least one step' };
}
for (const step of document.steps) {
if (stepIds.has(step.id)) {
return { ok: false, error: `duplicate runtime3 step id: ${step.id}` };
}
stepIds.add(step.id);
}
if (!stepIds.has(document.scenario.entry_step_id)) {
return { ok: false, error: 'entry_step_id does not exist in runtime3 steps' };
}
for (const step of document.steps) {
for (const transition of step.transitions) {
if (!stepIds.has(transition.target_step_id)) {
return {
ok: false,
error: `transition target missing: ${transition.target_step_id}`,
};
}
}
}
return { ok: true };
}
export function runtime3ToJson(document: Runtime3Document): string {
return JSON.stringify(document, null, 2);
}
-294
View File
@@ -1,294 +0,0 @@
import YAML from 'yaml';
import { z } from 'zod';
import type { ScenarioDocument, ScenarioStep, StepTransition } from '../types';
const scenarioSchema = z.object({
id: z.string().min(1),
version: z.number().int().positive(),
title: z.string().min(1),
players: z.object({
min: z.number().int().min(1),
max: z.number().int().min(1),
}),
duration: z.object({
total_minutes: z.number().int().positive(),
}),
canon: z.object({
introduction: z.string().min(1),
stakes: z.string().min(1),
}),
stations: z.array(z.record(z.string(), z.unknown())),
puzzles: z.array(z.record(z.string(), z.unknown())),
steps_narrative: z.array(
z.object({
step_id: z.string().min(1),
scene: z.string().min(1),
narrative: z.string().min(1),
}),
),
firmware: z
.object({
initial_step: z.string().min(1),
steps_reference_order: z.array(z.string().min(1)).optional(),
steps: z.array(
z.object({
step_id: z.string().min(1),
screen_scene_id: z.string().min(1),
audio_pack_id: z.string(),
actions: z.array(z.string()),
apps: z.array(z.string()),
transitions: z.array(
z.object({
event_type: z.string().min(1),
event_name: z.string().min(1),
target_step_id: z.string().min(1),
priority: z.number().int(),
after_ms: z.number().int().min(0),
}),
),
}),
),
})
.optional(),
});
export function normalizeId(raw: string, fallback: string): string {
const cleaned = raw
.trim()
.replace(/[^a-zA-Z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
return cleaned || fallback;
}
function normalizeTransition(transition: StepTransition): StepTransition {
return {
eventType: transition.eventType,
eventName: normalizeId(transition.eventName, 'BTN_NEXT'),
targetStepId: normalizeId(transition.targetStepId, 'STEP_NEXT'),
priority: transition.priority ?? 0,
afterMs: transition.afterMs ?? 0,
};
}
function resolveScenarioStepOrder(document: ScenarioDocument): string[] {
if (Array.isArray(document.firmware?.steps) && document.firmware.steps.length > 0) {
return document.firmware.steps.map((step) => step.step_id);
}
if (
Array.isArray(document.firmware?.steps_reference_order) &&
document.firmware.steps_reference_order.length > 0
) {
return document.firmware.steps_reference_order;
}
return document.steps_narrative.map((step) => step.step_id);
}
export function buildScenarioFromBlocks(
scenarioId: string,
steps: ScenarioStep[],
): ScenarioDocument {
const normalizedSteps = steps.map((step, index) => {
const position = index + 1;
return {
stepId: normalizeId(step.stepId, `STEP_${position}`),
sceneId: normalizeId(step.sceneId, `SCENE_${position}`),
audioPack: step.audioPack ?? '',
actions: step.actions ?? [],
apps: step.apps ?? [],
transitions: (step.transitions ?? []).map(normalizeTransition),
};
});
const fallbackStep =
normalizedSteps.length > 0
? normalizedSteps
: [
{
stepId: 'STEP_BOOT',
sceneId: 'SCENE_BOOT',
audioPack: '',
actions: [],
apps: [],
transitions: [],
},
];
const firmwareSteps = fallbackStep.map((step, i) => ({
step_id: step.stepId,
screen_scene_id: step.sceneId,
audio_pack_id: step.audioPack,
actions: step.actions,
apps: step.apps,
transitions: step.transitions.length
? step.transitions.map((transition) => ({
event_type: transition.eventType,
event_name: transition.eventName,
target_step_id: transition.targetStepId,
priority: transition.priority,
after_ms: transition.afterMs,
}))
: i < fallbackStep.length - 1
? [
{
event_type: 'serial',
event_name: 'BTN_NEXT',
target_step_id: fallbackStep[i + 1].stepId,
priority: 0,
after_ms: 0,
},
]
: [],
}));
return {
id: normalizeId(scenarioId, 'ZACUS_V2_NEW'),
version: 1,
title: 'Nouveau scenario Zacus',
players: { min: 6, max: 14 },
duration: { total_minutes: 105 },
canon: {
introduction: 'Scenario genere depuis frontend scratch-like.',
stakes: 'Valider les transitions et deployer via API Story V2.',
},
stations: [],
puzzles: [],
steps_narrative: fallbackStep.map((s) => ({
step_id: s.stepId,
scene: s.sceneId,
narrative: `Etape ${s.stepId} en scene ${s.sceneId}.`,
})),
firmware: {
initial_step: fallbackStep[0].stepId,
steps_reference_order: fallbackStep.map((step) => step.stepId),
steps: firmwareSteps,
},
};
}
export function validateScenarioDocument(
document: ScenarioDocument,
): { ok: true } | { ok: false; error: string } {
const parsed = scenarioSchema.safeParse(document);
if (!parsed.success) {
return {
ok: false,
error: parsed.error.issues[0]?.message ?? 'schema validation error',
};
}
if (parsed.data.players.min > parsed.data.players.max) {
return { ok: false, error: 'players.min must be <= players.max' };
}
const stepOrder = resolveScenarioStepOrder(parsed.data);
const uniqueStepIds = new Set(stepOrder);
if (uniqueStepIds.size !== stepOrder.length) {
return { ok: false, error: 'duplicate step ids in scenario step order' };
}
if (parsed.data.firmware) {
const runtimeStepIds = new Set(parsed.data.firmware.steps.map((step) => step.step_id));
if (!runtimeStepIds.has(parsed.data.firmware.initial_step)) {
return { ok: false, error: 'firmware.initial_step must exist in firmware.steps' };
}
for (const step of parsed.data.firmware.steps) {
for (const transition of step.transitions) {
if (!runtimeStepIds.has(transition.target_step_id)) {
return {
ok: false,
error: `transition target missing: ${transition.target_step_id}`,
};
}
}
}
if (
Array.isArray(parsed.data.firmware.steps_reference_order) &&
parsed.data.firmware.steps_reference_order.length > 0 &&
parsed.data.firmware.steps_reference_order.join('|') !==
parsed.data.firmware.steps.map((step) => step.step_id).join('|')
) {
return {
ok: false,
error: 'firmware.steps_reference_order must match firmware.steps order when both are present',
};
}
}
return { ok: true };
}
export function scenarioToYaml(document: ScenarioDocument): string {
return YAML.stringify(document);
}
/** Parse a YAML string back into steps for the Blockly designer */
export function parseYamlToSteps(
yamlStr: string,
): { id: string; steps: ScenarioStep[] } | { error: string } {
try {
const data = YAML.parse(yamlStr);
if (!data || typeof data !== 'object') {
return { error: 'Invalid YAML: not an object' };
}
const id = data.id ?? 'UNKNOWN';
const steps: ScenarioStep[] = [];
const normalizeTransitionList = (items: unknown): StepTransition[] => {
if (!Array.isArray(items)) return [];
return items.map((transition) => ({
eventType:
transition?.event_type ??
transition?.eventType ??
transition?.trigger ??
'serial',
eventName: transition?.event_name ?? transition?.eventName ?? 'BTN_NEXT',
targetStepId:
transition?.target_step_id ?? transition?.targetStepId ?? transition?.target ?? '',
priority: Number(transition?.priority ?? 0),
afterMs: Number(transition?.after_ms ?? transition?.afterMs ?? 0),
}));
};
const fwSteps = data.runtime3?.steps ?? data.firmware?.steps ?? data.steps;
if (Array.isArray(fwSteps)) {
for (const s of fwSteps) {
steps.push({
stepId: s.id ?? s.step_id ?? s.stepId ?? '',
sceneId: s.scene_id ?? s.screen_scene_id ?? s.sceneId ?? s.scene ?? '',
audioPack: s.audio_pack_id ?? '',
actions: s.actions ?? [],
apps: s.apps ?? [],
transitions: normalizeTransitionList(s.transitions),
});
}
}
// Fallback: steps_narrative
if (steps.length === 0 && Array.isArray(data.steps_narrative)) {
for (const s of data.steps_narrative) {
steps.push({
stepId: s.step_id ?? '',
sceneId: s.scene ?? '',
transitions: [],
});
}
}
// Fallback: firmware.steps_reference_order
if (steps.length === 0 && Array.isArray(data.firmware?.steps_reference_order)) {
for (const id of data.firmware.steps_reference_order) {
steps.push({ stepId: id, sceneId: id, transitions: [] });
}
}
if (steps.length === 0) {
return { error: 'No steps found in YAML' };
}
return { id, steps };
} catch (err) {
return { error: `YAML parse error: ${err instanceof Error ? err.message : err}` };
}
}
@@ -1,95 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type LegacyStatus,
type StoryStatus,
type StreamMessage,
connectStoryStream,
legacyStatus,
storyStatus,
} from './api';
export interface RuntimeState {
connected: boolean;
story: StoryStatus | null;
legacy: LegacyStatus | null;
lastError: string;
lastEvent: StreamMessage | null;
}
const POLL_INTERVAL = 3000;
export function useRuntimeStore() {
const [state, setState] = useState<RuntimeState>({
connected: false,
story: null,
legacy: null,
lastError: '',
lastEvent: null,
});
const wsRef = useRef<{ close: () => void } | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const poll = useCallback(async () => {
try {
const [story, legacy] = await Promise.all([
storyStatus().catch(() => null),
legacyStatus().catch(() => null),
]);
setState((prev) => ({
...prev,
connected: !!(story || legacy),
story: story ?? prev.story,
legacy: legacy ?? prev.legacy,
lastError: '',
}));
} catch (err) {
setState((prev) => ({
...prev,
connected: false,
lastError: err instanceof Error ? err.message : String(err),
}));
}
}, []);
useEffect(() => {
poll();
timerRef.current = setInterval(poll, POLL_INTERVAL);
try {
const ws = connectStoryStream(
(msg) => {
setState((prev) => ({ ...prev, lastEvent: msg }));
if (msg.type === 'step_change' || msg.type === 'status') {
poll();
}
},
() => {
/* ws error — polling will keep us alive */
},
);
wsRef.current = ws;
} catch {
/* no ws support */
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (wsRef.current) wsRef.current.close();
};
}, [poll]);
return { ...state, refresh: poll };
}
/** Detect if we're on the Media Manager screen */
export function isMediaManagerActive(legacy: LegacyStatus | null): boolean {
if (!legacy) return false;
const screen = legacy.story.screen ?? '';
const step = legacy.story.step ?? '';
return (
screen === 'SCENE_MEDIA_MANAGER' ||
step === 'STEP_MEDIA_MANAGER' ||
screen.includes('MEDIA_MANAGER') ||
step.includes('MEDIA_MANAGER')
);
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
-66
View File
@@ -1,66 +0,0 @@
export interface ScenarioStep {
stepId: string;
sceneId: string;
audioPack?: string;
actions?: string[];
apps?: string[];
transitions?: StepTransition[];
}
export interface StepTransition {
eventType:
| 'button'
| 'serial'
| 'timer'
| 'audio_done'
| 'unlock'
| 'espnow'
| 'action';
eventName: string;
targetStepId: string;
priority: number;
afterMs: number;
}
export interface ScenarioDocument {
id: string;
version: number;
title: string;
players: {
min: number;
max: number;
};
duration: {
total_minutes: number;
};
canon: {
introduction: string;
stakes: string;
};
stations: Array<Record<string, unknown>>;
puzzles: Array<Record<string, unknown>>;
steps_narrative: Array<{
step_id: string;
scene: string;
narrative: string;
}>;
firmware?: {
initial_step?: string;
steps_reference_order?: string[];
steps?: Array<{
step_id: string;
screen_scene_id: string;
audio_pack_id: string;
actions: string[];
apps: string[];
transitions: Array<{
trigger?: string;
event_type: string;
event_name: string;
target_step_id: string;
priority: number;
after_ms: number;
}>;
}>;
};
}
@@ -1,126 +0,0 @@
import { describe, expect, it } from 'vitest';
import YAML from 'yaml';
import { buildScenarioFromBlocks, parseYamlToSteps, validateScenarioDocument } from '../src/lib/scenario';
import { compileScenarioDocumentToRuntime3, validateRuntime3Document } from '../src/lib/runtime3';
import type { ScenarioDocument } from '../src/types';
function buildGraphFirstDocument(): ScenarioDocument {
return {
id: 'zacus_graph_first',
version: 3,
title: 'Zacus Graph First',
players: { min: 6, max: 12 },
duration: { total_minutes: 90 },
canon: {
introduction: 'Intro',
stakes: 'Stakes',
},
stations: [],
puzzles: [],
steps_narrative: [
{ step_id: 'STEP_BOOT', scene: 'SCENE_BOOT', narrative: 'Boot' },
{ step_id: 'STEP_GATE', scene: 'SCENE_GATE', narrative: 'Gate' },
{ step_id: 'STEP_DONE', scene: 'SCENE_DONE', narrative: 'Done' },
],
firmware: {
initial_step: 'STEP_BOOT',
steps: [
{
step_id: 'STEP_BOOT',
screen_scene_id: 'SCENE_BOOT',
audio_pack_id: '',
actions: [],
apps: [],
transitions: [
{
event_type: 'serial',
event_name: 'BTN_NEXT',
target_step_id: 'STEP_GATE',
priority: 100,
after_ms: 0,
},
],
},
{
step_id: 'STEP_GATE',
screen_scene_id: 'SCENE_GATE',
audio_pack_id: '',
actions: [],
apps: [],
transitions: [
{
event_type: 'unlock',
event_name: 'UNLOCK_GATE',
target_step_id: 'STEP_DONE',
priority: 120,
after_ms: 0,
},
],
},
{
step_id: 'STEP_DONE',
screen_scene_id: 'SCENE_DONE',
audio_pack_id: '',
actions: [],
apps: [],
transitions: [],
},
],
},
};
}
describe('graph-first scenario handling', () => {
it('validates a canonical graph-first document without steps_reference_order', () => {
const document = buildGraphFirstDocument();
expect(validateScenarioDocument(document)).toEqual({ ok: true });
});
it('parses firmware.steps from YAML before older fallback fields', () => {
const yaml = YAML.stringify(buildGraphFirstDocument());
const parsed = parseYamlToSteps(yaml);
if ('error' in parsed) {
throw new Error(parsed.error);
}
expect(parsed.steps.map((step) => step.stepId)).toEqual([
'STEP_BOOT',
'STEP_GATE',
'STEP_DONE',
]);
expect(parsed.steps[1]?.transitions?.[0]?.eventType).toBe('unlock');
});
it('compiles runtime3 entry step from firmware.initial_step', () => {
const runtime3 = compileScenarioDocumentToRuntime3(buildGraphFirstDocument());
expect(runtime3.scenario.entry_step_id).toBe('STEP_BOOT');
expect(runtime3.steps[1]?.transitions[0]?.event_type).toBe('unlock');
expect(validateRuntime3Document(runtime3)).toEqual({ ok: true });
});
it('keeps the builder output compatible with the graph-first validator', () => {
const built = buildScenarioFromBlocks('zacus_builder', [
{
stepId: 'STEP_BOOT',
sceneId: 'SCENE_BOOT',
transitions: [
{
eventType: 'serial',
eventName: 'BTN_NEXT',
targetStepId: 'STEP_DONE',
priority: 50,
afterMs: 0,
},
],
},
{
stepId: 'STEP_DONE',
sceneId: 'SCENE_DONE',
transitions: [],
},
]);
expect(validateScenarioDocument(built)).toEqual({ ok: true });
expect(built.firmware?.steps?.[0]?.step_id).toBe('STEP_BOOT');
expect(built.firmware?.steps_reference_order).toEqual(['STEP_BOOT', 'STEP_DONE']);
expect('steps_reference_order' in built).toBe(false);
});
});
-28
View File
@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+1 -7
View File
@@ -7,7 +7,7 @@ pnpm + turbo monorepo. Three apps + three shared packages. Node ≥20, pnpm ≥9
```
apps/
dashboard/ # Live game-master dashboard (analytics, control)
editor/ # Scenario editor (V3 successor of frontend-scratch-v2)
editor/ # Scenario editor (will fuse with simulation into atelier — see docs/superpowers/specs/2026-05-01-v3-fusion-atelier-design.md)
simulation/ # Runtime simulator / playtest UI
packages/
scenario-engine/ # Runtime 3 IR + execution (shared core)
@@ -34,12 +34,6 @@ pnpm lint # turbo run lint
- Components shared across ≥2 apps move to `packages/ui`. Keep app-local components in `apps/<app>/src/components`.
- Turbo cache keys: ensure `inputs` in `turbo.json` cover all source paths; missing globs cause stale cache.
## V2 vs V3
- `frontend-scratch-v2/` is the legacy single-app Vite playground. V3 is the production target.
- When porting features from V2, extract reusable logic into `packages/scenario-engine` or `packages/shared` first.
- Do not symlink or import across V2/V3 boundaries.
## Anti-Patterns
- Adding deps to root `package.json` (use the relevant app/package)
+1 -1
View File
@@ -21,7 +21,7 @@ Contracts between firmware, frontends, tooling, and AI agents. Treat each spec a
- A spec change is a contract change. List affected consumers in the PR description.
- Bump the version header at the top of the file when altering existing fields; never silently rename keys.
- New spec docs must be linked from root `CLAUDE.md` "Canonical Files" if they introduce a new contract.
- Pair runtime/frontend spec changes — the matching `tools/scenario/compile_runtime3.py` and `frontend-scratch-v2/src/lib/runtime3.ts` updates ship in the same PR.
- Pair runtime/frontend spec changes — the matching `tools/scenario/compile_runtime3.py` and `frontend-v3/packages/scenario-engine/src/engine.ts` updates ship in the same PR.
## Anti-Patterns
+11 -11
View File
@@ -17,7 +17,7 @@ PORTS_ARTIFACT_ROOT="$ARTIFACT_ROOT/ports"
LATEST_PORTS_JSON="$PORTS_ARTIFACT_ROOT/latest_ports_resolve.json"
CODEX_ARTIFACT_ROOT="$ARTIFACT_ROOT/codex"
RUNTIME3_ARTIFACT_ROOT="$ARTIFACT_ROOT/runtime3"
FRONTEND_ROOT="$REPO_ROOT/frontend-scratch-v2"
FRONTEND_ROOT="$REPO_ROOT/frontend-v3"
DEFAULT_SCENARIO="$REPO_ROOT/game/scenarios/zacus_v2.yaml"
DEFAULT_PORTS_FIXTURE="$REPO_ROOT/tools/test/fixtures/ports_list_macos.txt"
LAST_RESOLVED_JSON=""
@@ -46,9 +46,9 @@ Commands:
runtime3-verify Verify Runtime 3 pivot coverage on the canonical scenario
runtime3-test Run Runtime 3 unit tests
runtime3-firmware-bundle Export Runtime 3 JSON into the firmware LittleFS tree
frontend-lint Run the React/Blockly studio lint gate
frontend-test Run the React/Blockly studio test gate
frontend-build Run the React/Blockly studio build gate
frontend-typecheck Run the V3 monorepo typecheck gate
frontend-test Run the V3 monorepo test gate
frontend-build Run the V3 monorepo build gate
docs-build Build the MkDocs site in strict mode
artifacts-summary Show recent Zacus artifact directories
artifacts-prune Delete old Zacus artifact directories (explicit --yes required)
@@ -353,19 +353,19 @@ HELP
(cd "$REPO_ROOT" && python3 tools/scenario/export_runtime3_firmware_bundle.py "$scenario" -o "$out_file")
}
cmd_frontend_lint() {
info "running frontend lint"
(cd "$FRONTEND_ROOT" && npm run lint)
cmd_frontend_typecheck() {
info "running frontend typecheck"
(cd "$FRONTEND_ROOT" && pnpm typecheck)
}
cmd_frontend_test() {
info "running frontend tests"
(cd "$FRONTEND_ROOT" && npm run test)
(cd "$FRONTEND_ROOT" && pnpm test)
}
cmd_frontend_build() {
info "running frontend build"
(cd "$FRONTEND_ROOT" && npm run build)
(cd "$FRONTEND_ROOT" && pnpm build)
}
cmd_docs_build() {
@@ -802,8 +802,8 @@ case "$command" in
runtime3-firmware-bundle)
cmd_runtime3_firmware_bundle "$@"
;;
frontend-lint)
cmd_frontend_lint "$@"
frontend-typecheck)
cmd_frontend_typecheck "$@"
;;
frontend-test)
cmd_frontend_test "$@"
+4 -4
View File
@@ -204,16 +204,16 @@ def action_python_tests():
def action_frontend_tests():
return run_command(
"Run Frontend Tests",
"npx vitest run",
cwd=str(REPO_ROOT / "frontend-scratch-v2"),
"pnpm test",
cwd=str(REPO_ROOT / "frontend-v3"),
)
def action_build_frontend():
return run_command(
"Build Frontend",
"npm run build",
cwd=str(REPO_ROOT / "frontend-scratch-v2"),
"pnpm build",
cwd=str(REPO_ROOT / "frontend-v3"),
)