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:
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
-4364
File diff suppressed because it is too large
Load Diff
@@ -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 +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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 éàü & 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 +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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` }),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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 "$@"
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user