Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abdb6bec2c | |||
| 410e3a4ef7 | |||
| 0f11f049f9 | |||
| af5d99e901 | |||
| 0c1802285a | |||
| e478454070 | |||
| 0ac86b3c3d | |||
| 41c574b1c5 | |||
| 72412ca1fe | |||
| 616aeca14e | |||
| 2b6a952e10 | |||
| 24de4de4e2 | |||
| a9953172e4 | |||
| bfa09b68be | |||
| 05c4c11c0b | |||
| 036b3e347d | |||
| 8a9a5a2063 | |||
| f55da57b2c | |||
| fbe9c7dfb7 | |||
| e73db8800c | |||
| 92cbff1858 | |||
| 26046a859b | |||
| b80744d231 | |||
| 37eab47f63 | |||
| 42a3b22823 | |||
| dffcca961b | |||
| 892fb13bb3 | |||
| 5d7550b993 | |||
| 44cd100a80 | |||
| 73fa3650a1 | |||
| 643c3852de | |||
| 0764c873a7 | |||
| 27ab75fded | |||
| af7c02d1a7 | |||
| fb71e9ceeb | |||
| f703611388 | |||
| f8e576ba5a | |||
| 8d90a25756 | |||
| 1e15bba258 | |||
| bc5363cf2c | |||
| 49ec7d9dc7 | |||
| 20c0431f51 | |||
| 68c81fa3c9 | |||
| da205ffc99 | |||
| 97303f0c58 | |||
| 81e02a897a | |||
| 87aeb58171 | |||
| 0b75fc6ddb | |||
| 81e6a786c5 | |||
| 74fe9639d9 | |||
| 5d9b735a2d | |||
| 9c84ef9d3d | |||
| 5e88ddef65 | |||
| 502aa8dc5b | |||
| 0e517829f1 | |||
| f5de35342c | |||
| 40373a5693 | |||
| 3066e63b24 | |||
| b66724889b | |||
| 8f3991eca9 | |||
| 2b53c5be8e | |||
| 5da6ec4c11 | |||
| 6ccbf2c5c7 | |||
| 904a77a08b | |||
| ddb194f32b | |||
| 8232adb446 | |||
| e9563f693f | |||
| c8e03bc199 | |||
| e2da1b5ccd | |||
| 77dbfd4a45 | |||
| ad3347a8bf | |||
| d834d0acd2 | |||
| e409d1f48c | |||
| 8537b90371 | |||
| df24038638 | |||
| 56c44eaf4f | |||
| 1b67a4820c | |||
| 4bee6ccf82 | |||
| e075dee4f2 | |||
| b214cc3850 | |||
| 15936b4b40 | |||
| 5892c17d63 | |||
| 780a936391 | |||
| a88676b4cb | |||
| fab5cb0bdd | |||
| 2462a0c489 | |||
| bce56e121b | |||
| f94c18cb4d | |||
| c0b11890ec | |||
| e604bed824 | |||
| bdc4a75237 | |||
| bf4f4e9c88 | |||
| 2a1d964a86 | |||
| 939f30cf56 | |||
| 440b3bab5c | |||
| d45cd3cffd | |||
| 5f8115ad36 | |||
| 91522b4ed7 | |||
| 60a759f4a6 | |||
| 2fd528a504 | |||
| 13dc5deab0 | |||
| 377a020e62 | |||
| 46eebd0066 | |||
| 48368a1299 | |||
| b22ec686fd | |||
| 101d1b3364 | |||
| 127527e5ed | |||
| 0d736459f4 | |||
| e3b868cbe1 | |||
| 95f9e4fa98 | |||
| b8b4fb5f91 | |||
| 6d288160af | |||
| 265427a236 | |||
| 3ef4fbda53 | |||
| 39b0ec4456 | |||
| 24ab1f2f3e | |||
| ca80799393 | |||
| 9c9a24808a | |||
| 10434d5445 | |||
| 910584328d | |||
| b99db18225 | |||
| ac78f13669 | |||
| 7c7f112b7d | |||
| e0eb00bc97 | |||
| 9ee377587e | |||
| 432ea0aa1a | |||
| c6e517f8cf | |||
| f6bb3d3a41 | |||
| 808d8bca2a | |||
| 00f8770ec0 | |||
| f78d649527 | |||
| 4f4fedfda2 | |||
| c5f4de938e | |||
| bc5fe00d58 | |||
| df11635b0f | |||
| 3336e2cea2 | |||
| a5ebf7c68c | |||
| c5db426bec | |||
| e806fc9634 | |||
| 3e75632486 | |||
| b76f2d3c98 | |||
| 54b8d2a9f9 | |||
| b05264aafe | |||
| 2ecad365d1 | |||
| 6dd78b3a69 | |||
| 1f524a1ed7 | |||
| a85f39ba9a | |||
| 4d169bb990 | |||
| fcba0cd310 | |||
| b0fc2769d7 | |||
| fb860238b3 | |||
| 2779dc892a | |||
| 7dc42b7965 | |||
| ac2fa5c2b0 | |||
| 62eeaa016d | |||
| 083bf5a35a | |||
| 5f2ad757d4 | |||
| c7068380f3 | |||
| 6e837cc195 | |||
| a481f130d6 | |||
| 27544fecd1 | |||
| b1486b27ff | |||
| dd6df1fa69 | |||
| 7086411ea3 | |||
| f81601bebd | |||
| f71a69e4b7 | |||
| 261369d50b | |||
| 7e690f5a20 | |||
| 9e78dd7b6b | |||
| a4838dccf0 | |||
| 7ee051827f | |||
| 19bce1075a | |||
| 4ae92abcb3 | |||
| e1a116d0f5 | |||
| ec7fb2bac2 | |||
| 8b9151851f | |||
| 456be02133 | |||
| f5328d9d74 | |||
| f36eac67a2 | |||
| db63da7d2e | |||
| b84fb19c86 | |||
| 34b7c3144a | |||
| c325aee8c2 | |||
| 089cea409e | |||
| 6ddd0d872d | |||
| 102ee99729 | |||
| 57dd1fcd5d | |||
| 6ad17ab3ec | |||
| 611b4e38c8 | |||
| c1afa65c55 | |||
| e7a974e1be | |||
| bf1113c5ec | |||
| 26969640a3 |
+16
@@ -110,3 +110,19 @@ secrets.json
|
||||
|
||||
# Tailscale auth-keys / OAuth client files
|
||||
tailscale-*.key
|
||||
.worktrees/
|
||||
|
||||
# Build-time public config (committed, no secret): gateway URL for the prod web deploy
|
||||
!frontend-v3/apps/*/.env.webdeploy
|
||||
|
||||
# Generated PLIP SD voice pack (reproducible via tools/tts/generate_plip_sd_pack.py)
|
||||
tools/tts/plip_sd_pack/
|
||||
|
||||
# Generated gamebook WAV pack (reproducible via tools/gamebook/build_gamebook.py)
|
||||
tools/gamebook/build/
|
||||
|
||||
# Generated PLIP phone gamebook pack (reproducible)
|
||||
tools/gamebook/build_phone/
|
||||
|
||||
# Generated master gamebook narration pack (GAMEBOOK_OUT_DIR override)
|
||||
tools/gamebook/build_master/
|
||||
|
||||
+1
-1
Submodule ESP32_ZACUS updated: d220d94607...fd7af95bcf
@@ -23,6 +23,7 @@ pio device monitor # serial @ 115200
|
||||
| `src/phone_task.cpp` | Off-hook GPIO interrupt + ring control |
|
||||
| `src/audio_task.cpp` | ES8388 / Si3210 audio routing, drains a command queue |
|
||||
| `src/network_task.cpp` | WiFi station + REST server (`/ring`, `/play`, `/stop`, `/status`) |
|
||||
| `src/scenario_now.cpp` | ESP-NOW scenario hot-load receiver → LittleFS `/scenario.json` |
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "scenario_now.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kFallbackSsid[] = "ZACUS-SETUP";
|
||||
@@ -109,6 +111,9 @@ void network_task(void *) {
|
||||
const WifiCreds creds = load_credentials();
|
||||
if (connect_wifi(creds)) {
|
||||
start_mdns_and_probe_master();
|
||||
// Scenario hot-load receiver (ESP-NOW rides the station's channel).
|
||||
// Idempotent; safe to call again from the reconnect path below.
|
||||
scenario_now_init();
|
||||
} else {
|
||||
Serial.println(F("[net] proceeding offline — will retry every 5 s"));
|
||||
}
|
||||
@@ -125,6 +130,7 @@ void network_task(void *) {
|
||||
const WifiCreds fresh = load_credentials();
|
||||
if (connect_wifi(fresh)) {
|
||||
start_mdns_and_probe_master();
|
||||
scenario_now_init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// scenario_now — implementation. See scenario_now.h for the design notes.
|
||||
//
|
||||
// Threading: the esp_now recv callback runs in the Wi-Fi task on the
|
||||
// Arduino core; it only copies the frame onto a queue. Reassembly, the
|
||||
// LittleFS write and the consumer callback all run in the worker task.
|
||||
//
|
||||
// Failure policy mirrors the puzzle nodes' shared receiver
|
||||
// (ESP32_ZACUS/lib/espnow_common/espnow_slave.c): bounded heap reassembly
|
||||
// (<=64 KiB), 5 s sender-silence timeout, a new first frame (seq==0) from a
|
||||
// different sender preempts a stale transfer.
|
||||
|
||||
#include "scenario_now.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <LittleFS.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_now.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/task.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef SCENARIO_NOW_QUEUE_DEPTH
|
||||
#define SCENARIO_NOW_QUEUE_DEPTH 16
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kTag[] = "[scenario-now]";
|
||||
constexpr char kPath[] = "/scenario.json";
|
||||
constexpr char kTmpPath[] = "/scenario.json.tmp";
|
||||
|
||||
// Wire format constants — must match scenario_mesh / espnow_slave.c.
|
||||
constexpr size_t kPayloadMax = 236; // 240 ESP-NOW max - 4 header
|
||||
constexpr size_t kMaxBytes = 64 * 1024; // master-side cap
|
||||
constexpr uint16_t kMaxFrames = (kMaxBytes + kPayloadMax - 1) / kPayloadMax;
|
||||
constexpr uint32_t kTimeoutMs = 5000;
|
||||
|
||||
struct FrameItem {
|
||||
uint8_t mac[6];
|
||||
uint8_t data[250];
|
||||
int len;
|
||||
};
|
||||
|
||||
struct Reassembly {
|
||||
bool active;
|
||||
uint8_t src[6];
|
||||
uint16_t total; // expected frame count
|
||||
uint16_t count; // distinct frames received
|
||||
size_t tail; // highest payload end offset = reassembled length
|
||||
uint8_t *buf; // heap, total * kPayloadMax
|
||||
uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes
|
||||
uint32_t last_ms;
|
||||
};
|
||||
|
||||
QueueHandle_t g_queue = nullptr;
|
||||
Reassembly g_rx = {};
|
||||
scenario_now_callback_t g_cb = nullptr;
|
||||
bool g_fs_ok = false;
|
||||
|
||||
void rx_reset() {
|
||||
free(g_rx.buf);
|
||||
free(g_rx.seen);
|
||||
memset(&g_rx, 0, sizeof(g_rx));
|
||||
}
|
||||
|
||||
// Persist atomically: write the temp file, then rename over the live path.
|
||||
// A reset mid-write leaves at worst a stale .tmp, never a torn scenario.
|
||||
bool persist(const uint8_t *json, size_t len) {
|
||||
if (!g_fs_ok) {
|
||||
Serial.printf("%s LittleFS unavailable, scenario not persisted\n", kTag);
|
||||
return false;
|
||||
}
|
||||
File f = LittleFS.open(kTmpPath, "w");
|
||||
if (!f) {
|
||||
Serial.printf("%s open %s failed\n", kTag, kTmpPath);
|
||||
return false;
|
||||
}
|
||||
const size_t written = f.write(json, len);
|
||||
f.close();
|
||||
if (written != len) {
|
||||
Serial.printf("%s short write %u/%u to %s\n",
|
||||
kTag, (unsigned)written, (unsigned)len, kTmpPath);
|
||||
LittleFS.remove(kTmpPath);
|
||||
return false;
|
||||
}
|
||||
LittleFS.remove(kPath); // rename does not overwrite on LittleFS
|
||||
if (!LittleFS.rename(kTmpPath, kPath)) {
|
||||
Serial.printf("%s rename %s -> %s failed\n", kTag, kTmpPath, kPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void feed(const FrameItem &it) {
|
||||
if (it.len < 4) return;
|
||||
const uint16_t seq = (uint16_t)it.data[0] | ((uint16_t)it.data[1] << 8);
|
||||
const uint16_t total = (uint16_t)it.data[2] | ((uint16_t)it.data[3] << 8);
|
||||
const uint8_t *payload = it.data + 4;
|
||||
int payload_len = it.len - 4;
|
||||
|
||||
// Begin (or restart from a new sender) on a first frame.
|
||||
if (seq == 0 && (!g_rx.active || memcmp(it.mac, g_rx.src, 6) != 0)) {
|
||||
rx_reset();
|
||||
if (total == 0 || total > kMaxFrames) {
|
||||
Serial.printf("%s bad/oversized total %u — dropped\n", kTag, total);
|
||||
return;
|
||||
}
|
||||
g_rx.buf = (uint8_t *)calloc(total, kPayloadMax);
|
||||
g_rx.seen = (uint8_t *)calloc((total + 7) / 8, 1);
|
||||
if (!g_rx.buf || !g_rx.seen) {
|
||||
Serial.printf("%s OOM for %u frames — dropped\n", kTag, total);
|
||||
rx_reset();
|
||||
return;
|
||||
}
|
||||
g_rx.active = true;
|
||||
g_rx.total = total;
|
||||
memcpy(g_rx.src, it.mac, 6);
|
||||
}
|
||||
|
||||
if (!g_rx.active) return; // stray frame, no session
|
||||
if (memcmp(it.mac, g_rx.src, 6) != 0) return; // other sender mid-transfer
|
||||
if (total != g_rx.total) return; // inconsistent header
|
||||
if (seq >= g_rx.total) return;
|
||||
if (payload_len > (int)kPayloadMax) payload_len = kPayloadMax;
|
||||
|
||||
g_rx.last_ms = millis();
|
||||
|
||||
if (!(g_rx.seen[seq / 8] & (1u << (seq % 8)))) {
|
||||
const size_t off = (size_t)seq * kPayloadMax;
|
||||
memcpy(g_rx.buf + off, payload, (size_t)payload_len);
|
||||
g_rx.seen[seq / 8] |= (uint8_t)(1u << (seq % 8));
|
||||
g_rx.count++;
|
||||
const size_t end = off + (size_t)payload_len;
|
||||
if (end > g_rx.tail) g_rx.tail = end;
|
||||
}
|
||||
|
||||
if (g_rx.count != g_rx.total) return;
|
||||
|
||||
// Complete. Cheap sanity check before touching flash: an IR is a JSON
|
||||
// object, so the first non-whitespace byte must be '{'.
|
||||
size_t i = 0;
|
||||
while (i < g_rx.tail && isspace(g_rx.buf[i])) i++;
|
||||
if (i == g_rx.tail || g_rx.buf[i] != '{') {
|
||||
Serial.printf("%s reassembled %u bytes but not JSON — dropped\n",
|
||||
kTag, (unsigned)g_rx.tail);
|
||||
rx_reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("%s scenario reassembled: %u frames, %u bytes from "
|
||||
"%02X:%02X:%02X:%02X:%02X:%02X\n",
|
||||
kTag, g_rx.total, (unsigned)g_rx.tail,
|
||||
g_rx.src[0], g_rx.src[1], g_rx.src[2],
|
||||
g_rx.src[3], g_rx.src[4], g_rx.src[5]);
|
||||
|
||||
if (persist(g_rx.buf, g_rx.tail)) {
|
||||
Serial.printf("%s stored at %s\n", kTag, kPath);
|
||||
}
|
||||
if (g_cb) {
|
||||
g_cb(g_rx.src, (const char *)g_rx.buf, g_rx.tail);
|
||||
} else {
|
||||
Serial.printf("%s no consumer registered — stored only\n", kTag);
|
||||
}
|
||||
rx_reset();
|
||||
}
|
||||
|
||||
void on_recv(const uint8_t *mac, const uint8_t *data, int len) {
|
||||
FrameItem item;
|
||||
memcpy(item.mac, mac, 6);
|
||||
const int copy_len = len < (int)sizeof(item.data) ? len : (int)sizeof(item.data);
|
||||
memcpy(item.data, data, copy_len);
|
||||
item.len = copy_len;
|
||||
// Non-blocking: under burst the frame is lost and the 5 s timeout (or the
|
||||
// master's next relay attempt) recovers — same policy as the puzzle nodes.
|
||||
xQueueSend(g_queue, &item, 0);
|
||||
}
|
||||
|
||||
void worker_task(void *) {
|
||||
Serial.printf("%s worker ready, storage=%s\n", kTag, kPath);
|
||||
FrameItem item;
|
||||
for (;;) {
|
||||
// Wake at least once a second so the sender-silence timeout fires even
|
||||
// with an empty queue.
|
||||
if (xQueueReceive(g_queue, &item, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||||
feed(item);
|
||||
}
|
||||
if (g_rx.active && (millis() - g_rx.last_ms) > kTimeoutMs) {
|
||||
Serial.printf("%s rx timeout (%u/%u frames) — dropped\n",
|
||||
kTag, g_rx.count, g_rx.total);
|
||||
rx_reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool scenario_now_init() {
|
||||
if (g_queue != nullptr) return true; // idempotent — reconnect path safe
|
||||
|
||||
// network_task owns Wi-Fi; esp_now_init() requires the stack started.
|
||||
if (WiFi.getMode() == WIFI_MODE_NULL) {
|
||||
Serial.printf("%s init before Wi-Fi start — call after network_task is up\n",
|
||||
kTag);
|
||||
return false;
|
||||
}
|
||||
|
||||
g_fs_ok = LittleFS.begin(true /* format on first mount */);
|
||||
if (!g_fs_ok) {
|
||||
Serial.printf("%s LittleFS mount failed — receiver runs without storage\n",
|
||||
kTag);
|
||||
} else if (LittleFS.exists(kPath)) {
|
||||
File f = LittleFS.open(kPath, "r");
|
||||
Serial.printf("%s existing scenario on flash: %u bytes\n",
|
||||
kTag, f ? (unsigned)f.size() : 0u);
|
||||
if (f) f.close();
|
||||
}
|
||||
|
||||
g_queue = xQueueCreate(SCENARIO_NOW_QUEUE_DEPTH, sizeof(FrameItem));
|
||||
if (g_queue == nullptr) {
|
||||
Serial.printf("%s xQueueCreate failed\n", kTag);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (esp_now_init() != ESP_OK) {
|
||||
Serial.printf("%s esp_now_init failed\n", kTag);
|
||||
vQueueDelete(g_queue);
|
||||
g_queue = nullptr;
|
||||
return false;
|
||||
}
|
||||
if (esp_now_register_recv_cb(on_recv) != ESP_OK) {
|
||||
Serial.printf("%s esp_now_register_recv_cb failed\n", kTag);
|
||||
esp_now_deinit();
|
||||
vQueueDelete(g_queue);
|
||||
g_queue = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (xTaskCreate(worker_task, "scenario-now", 6144, nullptr, 4, nullptr)
|
||||
!= pdPASS) {
|
||||
Serial.printf("%s xTaskCreate failed\n", kTag);
|
||||
esp_now_unregister_recv_cb();
|
||||
esp_now_deinit();
|
||||
vQueueDelete(g_queue);
|
||||
g_queue = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("%s ESP-NOW receiver up (channel follows the AP)\n", kTag);
|
||||
return true;
|
||||
}
|
||||
|
||||
void scenario_now_register_callback(scenario_now_callback_t cb) {
|
||||
g_cb = cb;
|
||||
}
|
||||
|
||||
const char *scenario_now_storage_path() {
|
||||
return kPath;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// scenario_now — ESP-NOW scenario hot-load receiver for PLIP.
|
||||
//
|
||||
// Brings PLIP into the scope of the firmware scenario hot-load
|
||||
// (docs/specs/2026-05-24-firmware-scenario-hotload.md, task 6). The Zacus
|
||||
// master relays a Runtime 3 scenario IR (JSON) over ESP-NOW in frames of
|
||||
// up to 240 bytes: a 4-byte header { seq:u16 LE, total:u16 LE } followed by
|
||||
// <=236 payload bytes. Same wire format as the master's scenario_mesh
|
||||
// component and the puzzle nodes' shared espnow_slave.c receiver.
|
||||
//
|
||||
// PLIP registers no other esp_now recv callback, so this module owns the
|
||||
// single callback — no demultiplexing against legacy MSG_* traffic needed
|
||||
// (unlike the puzzle nodes). Frames are copied onto a FreeRTOS queue from
|
||||
// the Wi-Fi task and reassembled per source MAC in a dedicated worker task;
|
||||
// a completed scenario is persisted to LittleFS at /scenario.json
|
||||
// (temp-then-rename, so a torn write never replaces a good scenario).
|
||||
//
|
||||
// Wi-Fi: network_task owns the connection. Call scenario_now_init() once
|
||||
// the station is up (esp_now_init() fails before Wi-Fi starts); it is
|
||||
// idempotent so the reconnect path may call it again.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Start the receiver: ESP-NOW init, recv callback, worker task, LittleFS
|
||||
// mount. Idempotent. Returns true when the receiver is (already) running.
|
||||
bool scenario_now_init();
|
||||
|
||||
// Optional consumer hook, invoked from the worker task after the scenario
|
||||
// has been persisted to LittleFS. `json` points at the reassembled buffer
|
||||
// (valid only for the duration of the call). PLIP has no Runtime 3 engine
|
||||
// yet: with no callback registered the scenario is stored and logged, ready
|
||||
// for a future engine to load at boot.
|
||||
typedef void (*scenario_now_callback_t)(const uint8_t src_mac[6],
|
||||
const char *json, size_t len);
|
||||
void scenario_now_register_callback(scenario_now_callback_t cb);
|
||||
|
||||
// Path of the persisted scenario on LittleFS.
|
||||
const char *scenario_now_storage_path();
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -6,10 +6,12 @@ Zacus est en refonte vers un produit hybride unique:
|
||||
- un runtime portable "Zacus Runtime 3" compile depuis le YAML canonique.
|
||||
|
||||
## Canon actuel
|
||||
- Source narrative: `game/scenarios/zacus_v2.yaml`
|
||||
- Studio auteur: `frontend-scratch-v2/`
|
||||
- Source narrative: `game/scenarios/zacus_v3_complete.yaml`
|
||||
- Studio auteur: `frontend-v3/apps/atelier` (monorepo pnpm + turbo ; `frontend-scratch-v2/` est abandonné depuis le hard cutover du 2026-05)
|
||||
- Hub game-master: `apps/zacus-hub` (app SwiftUI native)
|
||||
- Gateway d'orchestration: `tools/zacus-gateway` (FastAPI, port 8400) — compile le YAML → Runtime 3 IR et pousse aux cartes
|
||||
- Runtime portable: `tools/scenario/compile_runtime3.py` + `tools/scenario/simulate_runtime3.py`
|
||||
- Cible hardware principale: `hardware/firmware` avec `freenove_esp32s3`
|
||||
- Cible hardware principale: `ESP32_ZACUS/idf_zacus` (ESP-IDF, Freenove ESP32-S3) — submodule sur Gitea ; `hardware/firmware` (ancien build Arduino) est legacy
|
||||
- Plans et memoire: `memory/`, `plans/`, `todos/`
|
||||
- Architecture et cartes Mermaid: `docs/architecture/`
|
||||
|
||||
@@ -23,17 +25,20 @@ bash tools/test/run_content_checks.sh
|
||||
|
||||
### 2. Compiler et simuler Runtime 3
|
||||
```bash
|
||||
python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v2.yaml
|
||||
python3 tools/scenario/simulate_runtime3.py game/scenarios/zacus_v2.yaml
|
||||
python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml
|
||||
python3 tools/scenario/compile_runtime3.py game/scenarios/zacus_v3_complete.yaml
|
||||
python3 tools/scenario/simulate_runtime3.py game/scenarios/zacus_v3_complete.yaml
|
||||
python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v3_complete.yaml
|
||||
```
|
||||
|
||||
### 3. Démarrer le studio React + Blockly
|
||||
```bash
|
||||
cd frontend-scratch-v2
|
||||
npm install
|
||||
npm test
|
||||
VITE_STORY_API_BASE=http://<esp_ip>:8080 npm run dev
|
||||
cd frontend-v3
|
||||
pnpm install
|
||||
pnpm test
|
||||
# Bases de liaison : gateway (:8400), firmware (:80), hints (:8311), voice-bridge (:8200)
|
||||
VITE_GATEWAY_BASE_URL=http://localhost:8400 \
|
||||
VITE_ESP32_BASE_URL=http://zacus-master.local \
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 4. Utiliser le shell canonique
|
||||
@@ -52,8 +57,12 @@ make runtime3-test
|
||||
- `audio/`: manifestes audio et assets associés.
|
||||
- `printables/`: manifestes et exports imprimables.
|
||||
- `kit-maitre-du-jeu/`: matériel MJ et déroulé terrain.
|
||||
- `frontend-scratch-v2/`: studio auteur React + Blockly.
|
||||
- `hardware/firmware/`: firmware, APIs device, scripts terrain.
|
||||
- `frontend-v3/`: studio auteur (apps/atelier + apps/dashboard), monorepo pnpm + turbo. **Webapp courante.**
|
||||
- `apps/zacus-hub/`: hub game-master (app SwiftUI native).
|
||||
- `tools/zacus-gateway/`: gateway FastAPI (:8400) — orchestration studio/flash, compile YAML → Runtime 3 IR.
|
||||
- `ESP32_ZACUS/`: firmware ESP-IDF (submodule Gitea), cible principale `idf_zacus`.
|
||||
- `frontend-scratch-v2/`: studio auteur React + Blockly (legacy, abandonné).
|
||||
- `hardware/firmware/`: ancien firmware Arduino freenove (legacy).
|
||||
- `tools/`: validateurs, compilateur/simulateur Runtime 3, shells d'automatisation.
|
||||
- `docs/`: quickstart, architecture, benchmark OSS et runbooks.
|
||||
- `memory/`, `plans/`, `todos/`: pilotage de la refonte.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ struct GameMasterView: View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
header
|
||||
backendsSection
|
||||
sessionsSection
|
||||
sceneSection
|
||||
actionsSection
|
||||
if !actionLog.isEmpty { logSection }
|
||||
@@ -61,6 +62,39 @@ struct GameMasterView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionsSection: some View {
|
||||
GroupBox("Groupes en cours") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("\(session.state.sessions_active) groupe(s)", systemImage: "person.3.fill")
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
Label("\(session.state.hints_total) indice(s)", systemImage: "lightbulb")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if session.state.sessions.isEmpty {
|
||||
Text(session.state.state_detail ?? "Aucune session active.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(session.state.sessions) { snap in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(snap.total_penalty > 0 ? .orange : .green)
|
||||
Text(snap.session_id).font(.body.monospaced())
|
||||
if let puzzle = snap.active_puzzle {
|
||||
Text(puzzle).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(snap.total_hints) ind · \(snap.total_penalty) pén")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sceneSection: some View {
|
||||
GroupBox("Partie") {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
|
||||
|
||||
@@ -119,11 +119,24 @@ struct HubConfig: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionSnapshot: Codable, Equatable, Identifiable, Hashable {
|
||||
var session_id: String
|
||||
var total_hints: Int = 0
|
||||
var total_penalty: Int = 0
|
||||
var active_puzzle: String?
|
||||
var last_activity_ms: Int = 0
|
||||
var id: String { session_id }
|
||||
}
|
||||
|
||||
struct GameState: Codable, Equatable {
|
||||
var scene_id: String?
|
||||
var scene_index: Int = 0
|
||||
var last_hint: String?
|
||||
var voice_session: String?
|
||||
var backends: [BackendHealth] = []
|
||||
var sessions: [SessionSnapshot] = []
|
||||
var hints_total: Int = 0
|
||||
var sessions_active: Int = 0
|
||||
var state_detail: String?
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Contrat CMD ESP-NOW — cartes annexes Zacus (roadmap)
|
||||
|
||||
**Statut** : design / roadmap (D5 du redesign DSL). Aucun firmware annexe n'implémente encore ce contrat — ce document le *spécifie* pour que les cartes M5 / BOX-3 / PLIP / p7_coffre le décodent plus tard.
|
||||
|
||||
## 1. État actuel (constaté)
|
||||
|
||||
Le master Freenove relaie vers ses pairs ESP-NOW de deux façons :
|
||||
- **IR de scénario** : `POST /game/scenario/relay {peers,ir}` → `scenario_mesh_send` (chunké, ≤64 KiB, ack 300 ms). Sert à *pousser un scénario* à une carte.
|
||||
- **Canal CMD texte** : `scenario_mesh_send_text` — **texte libre UTF-8 ≤200 octets**, déclenché par `POST /game/espnow/cmd {peer, command}`. Aujourd'hui le master émet la trame (`{ok:true, ESP_OK}`) mais **aucun executor annexe ne consomme les CMD entrants** (elles sont seulement loguées).
|
||||
|
||||
Le routage gateway D2 (`/v1/media/play`, `/v1/step/{board}`) utilise déjà ce canal en best-effort avec des commandes texte ad-hoc : `play:<path>`, `step:<id>`. **D5 formalise ce texte ad-hoc en schéma structuré.**
|
||||
|
||||
## 2. Schéma CMD structuré
|
||||
|
||||
Trame = **JSON compact, UTF-8, ≤ 200 octets** (limite `scenario_mesh_send_text`). Forme :
|
||||
|
||||
```json
|
||||
{"op":"<verbe>","a":{<args>}}
|
||||
```
|
||||
|
||||
- `op` : verbe court (≤12 car.).
|
||||
- `a` : objet d'arguments (clés courtes pour tenir dans 200 o). Absent si pas d'argument.
|
||||
- Pas d'espaces superflus (séparateurs `,`/`:` compacts). Encodage côté gateway ; décodage côté carte annexe.
|
||||
|
||||
### Verbes (v1)
|
||||
|
||||
| `op` | Args (`a`) | Sémantique | Carte cible typique |
|
||||
|---|---|---|---|
|
||||
| `play` | `{"p":"<path>","l":<0\|1>}` | Jouer un média par chemin SD/LittleFS (`l`=loop) | toutes (audio) |
|
||||
| `stop` | — | Stopper l'audio | toutes |
|
||||
| `vol` | `{"v":<0-100>}` | Volume | toutes |
|
||||
| `step` | `{"s":"<step_id>"}` | Armer une étape | toutes |
|
||||
| `screen` | `{"t":"<title>","u":"<subtitle>","y":"<symbol>","e":"<effect>"}` | Afficher une scène LCD | M5/BOX-3 (écran) |
|
||||
| `led` | `{"p":"<pattern>"}` | Motif LED | M5/BOX-3 |
|
||||
| `ring` | `{"d":<sec>}` | Faire sonner (PLIP) | PLIP |
|
||||
| `evt` | `{"n":"<name>"}` | Remonter un événement vers le master (sens carte→master) | toutes |
|
||||
|
||||
### Exemples (≤200 o)
|
||||
|
||||
```json
|
||||
{"op":"play","a":{"p":"zacus/box3/intro.wav","l":0}}
|
||||
{"op":"step","a":{"s":"STEP_LEFOU"}}
|
||||
{"op":"screen","a":{"t":"LABORATOIRE","y":"LA","e":"pulse"}}
|
||||
{"op":"evt","a":{"n":"solved:1"}}
|
||||
```
|
||||
|
||||
## 3. Sens master → carte (CMD) et carte → master (EVT)
|
||||
|
||||
- **CMD** (master→pair) : émise par le gateway via `POST /game/espnow/cmd {peer, command:<json>}`. Le master relaie la trame ; la carte annexe décode `{op,a}` et exécute.
|
||||
- **EVT** (pair→master) : la carte annexe émet `{"op":"evt","a":{"n":"<name>"}}` ; le master la remonte au gateway (endpoint à définir, p.ex. réutiliser le log CMD/EVT entrant). Les `evt` (`solved:<id>`, `button:<x>`, `done`) alimentent les **transitions réelles** (D4) côté gateway : `evt n="solved:1"` ↔ transition `onPuzzleSolved(1)` (`event_type:unlock`, `event_name:puzzle:1`).
|
||||
|
||||
## 4. Implémentation gateway (présent / futur)
|
||||
|
||||
- **Présent (D2)** : `/v1/media/play` et `/v1/step/{board}` envoient déjà du texte (`play:<path>`, `step:<id>`) via `/game/espnow/cmd`. À migrer vers le JSON structuré ci-dessus (changement local, sans toucher le firmware master).
|
||||
- **Futur** : un endpoint d'**EVT entrant** (le master expose les CMD/EVT reçues) pour fermer la boucle carte→gateway→transition.
|
||||
|
||||
## 5. Implémentation firmware annexe (futur, hors de ce repo-ci)
|
||||
|
||||
Chaque carte annexe (M5 / BOX-3 / PLIP / p7_coffre) doit :
|
||||
1. recevoir la trame ESP-NOW texte (callback existant côté master à relayer/exposer) ;
|
||||
2. parser le JSON `{op,a}` (parser compact, tolérant : trame inconnue → ignorée + log) ;
|
||||
3. dispatcher sur `op` vers ses capacités locales (audio I2S, écran, LED, sonnerie…) ;
|
||||
4. émettre des `evt` pour les événements joueur (énigme résolue, bouton…).
|
||||
|
||||
Tant que ce firmware n'existe pas, les blocs de la **voie 🔬 hardware futur** (M5/BOX-3/PLIP, etc.) restent validés mais non exécutés — le compilateur émet le warning « hardware non câblé » (D1), et le routage relais du gateway répond `ok:false` + warning D5 (cf. `/v1/media/play`, `/v1/step` sur une carte annexe).
|
||||
|
||||
## 6. Contraintes & notes
|
||||
|
||||
- **Budget 200 o** : privilégier des clés courtes (`p`,`s`,`t`,`v`…). Un `screen` avec title+subtitle longs peut dépasser → tronquer côté gateway (limites firmware : title≤47, subtitle≤63, symbol≤15) avant émission.
|
||||
- **Idempotence / ordre** : ESP-NOW n'est pas fiable ordonné ; les `op` doivent être idempotents quand c'est possible (`step`, `screen`, `vol`). Pour les séquences critiques, préférer le push d'IR complet (`/game/scenario/relay`).
|
||||
- **Versionnement** : si le schéma évolue, ajouter un champ `v` au niveau racine (`{"v":2,"op":...}`) ; absence = v1.
|
||||
@@ -0,0 +1,816 @@
|
||||
# Énigmes locales Freenove (P1 son, P3 QR) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Faire de la Freenove ESP32-S3 Media Kit v1.2 (N16R8) un master portable tout-en-un qui scanne les QR (P3) via sa caméra et valide une mélodie (P1) via son micro, en réinjectant les résultats dans le master comme une énigme ESP-NOW.
|
||||
|
||||
**Architecture:** Nouveau composant `idf_zacus/components/local_puzzles/` (mic_broker, qr_puzzle, sound_puzzle, local_puzzle_report). Les validateurs de séquence sont de la logique C pure testée sur hôte (Unity). L'intégration matérielle (esp32-camera + quirc, refacto micro du voice_pipeline) vient ensuite. Une fondation d'agrégation « puzzle résolu » est ajoutée au master car elle n'existe pas encore.
|
||||
|
||||
**Tech Stack:** ESP-IDF 5.4, `espressif/esp32-camera`, `quirc` (décodeur QR C), `espressif/esp-sr` (existant), Unity (tests hôte IDF).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-10-freenove-local-puzzles-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Fichier | Responsabilité |
|
||||
|---|---|
|
||||
| `idf_zacus/components/local_puzzles/include/local_puzzles.h` | API publique (init, report, arm/disarm par énigme) |
|
||||
| `idf_zacus/components/local_puzzles/seq_validator.c/.h` | Validateur de séquence QR (ordre) — **logique pure** |
|
||||
| `idf_zacus/components/local_puzzles/melody_validator.c/.h` | Validateur de mélodie (notes + tolérance) — **logique pure** |
|
||||
| `idf_zacus/components/local_puzzles/mic_broker.c/.h` | Possède l'I2S RX, aiguille les frames par mode |
|
||||
| `idf_zacus/components/local_puzzles/qr_puzzle.c` | Caméra + quirc + seq_validator |
|
||||
| `idf_zacus/components/local_puzzles/sound_puzzle.c` | Frames micro + détection notes + melody_validator |
|
||||
| `idf_zacus/components/local_puzzles/local_puzzles.c` | Câblage, `local_puzzle_report`, arm/disarm |
|
||||
| `idf_zacus/components/local_puzzles/CMakeLists.txt` | Déclaration composant + REQUIRES |
|
||||
| `idf_zacus/components/local_puzzles/idf_component.yml` | Deps managées (esp32-camera, quirc) |
|
||||
| `idf_zacus/components/local_puzzles/test/test_validators.c` | Tests Unity hôte des deux validateurs |
|
||||
| `idf_zacus/components/puzzle_state/…` | **Fondation** : agrégation des codes d'énigme + `POST /game/puzzle_solved` |
|
||||
| `idf_zacus/components/voice_pipeline/voice_pipeline.c` | **Modifié** : tire le micro du broker |
|
||||
| `idf_zacus/main/main.c` | **Modifié** : init puzzle_state + local_puzzles |
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Réconcilier le pin-map Media Kit v1.2 (BLOQUANT, pas de code)
|
||||
|
||||
**Pourquoi bloquant :** le `voice_pipeline` occupe déjà `GPIO14` (BCLK), `GPIO15` (WS), `GPIO22` (DIN micro) et `GPIO11/12/13` (haut-parleur MAX98357A). Le pin-map caméra Freenove générique réutilise `GPIO15` (XCLK), `GPIO13` (PCLK), `GPIO11/12` (data) → **collision directe**. Sans le vrai brochage du Media Kit v1.2, le code GPIO de la caméra serait faux.
|
||||
|
||||
- [ ] **Step 1: Extraire le brochage réel**
|
||||
|
||||
Récupérer, depuis le schéma officiel **Freenove ESP32-S3 Media Kit v1.2** (PDF fourni avec le kit / dépôt Freenove `Freenove_Media_Kit_for_ESP32_S3`), les GPIO de : caméra (PWDN, RESET, XCLK, SIOD, SIOC, VSYNC, HREF, PCLK, D0-D7), micro I2S (BCLK, WS, DIN), haut-parleur (BCLK, LRC, DIN).
|
||||
|
||||
- [ ] **Step 2: Vérifier la coexistence**
|
||||
|
||||
Construire un tableau GPIO unique. Confirmer **zéro collision** entre caméra, micro et haut-parleur. Si collision réelle sur le Media Kit (caméra et micro partagent un bus), documenter la conséquence : P3 (caméra) et P1/NPC (micro) deviennent **mutuellement exclusifs dans le temps** — acceptable car déjà séquentiels par phase de jeu, mais à acter.
|
||||
|
||||
- [ ] **Step 3: Geler les constantes**
|
||||
|
||||
Écrire le tableau final dans `idf_zacus/components/local_puzzles/include/board_pins_mediakit.h` (macros `CAM_PIN_*`, et confirmer que `voice_pipeline` garde ses pins micro actuels). Commit :
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/include/board_pins_mediakit.h
|
||||
git commit -m "docs(local): freeze Media Kit v1.2 pin map"
|
||||
```
|
||||
|
||||
> **Sortie de Task 0** : `board_pins_mediakit.h` avec les `CAM_PIN_*` confirmés. Les tâches caméra en dépendent.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Validateur de séquence QR (logique pure, testable hôte)
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/local_puzzles/seq_validator.h`
|
||||
- Create: `idf_zacus/components/local_puzzles/seq_validator.c`
|
||||
- Test: `idf_zacus/components/local_puzzles/test/test_validators.c`
|
||||
|
||||
- [ ] **Step 1: Écrire le test qui échoue**
|
||||
|
||||
```c
|
||||
#include "unity.h"
|
||||
#include "seq_validator.h"
|
||||
|
||||
TEST_CASE("seq accepts codes scanned in the expected order", "[seq]")
|
||||
{
|
||||
const char *expected[] = {"qr3", "qr1", "qr4"};
|
||||
seq_validator_t v;
|
||||
seq_validator_init(&v, expected, 3);
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr3")); // 1/3
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr1")); // 2/3
|
||||
TEST_ASSERT_TRUE (seq_validator_feed(&v, "qr4")); // complete
|
||||
}
|
||||
|
||||
TEST_CASE("seq resets on a wrong scan", "[seq]")
|
||||
{
|
||||
const char *expected[] = {"a", "b"};
|
||||
seq_validator_t v;
|
||||
seq_validator_init(&v, expected, 2);
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "x")); // wrong -> reset
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2 again
|
||||
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b")); // complete
|
||||
}
|
||||
|
||||
TEST_CASE("seq ignores a duplicate of the last correct scan", "[seq]")
|
||||
{
|
||||
const char *expected[] = {"a", "b"};
|
||||
seq_validator_t v;
|
||||
seq_validator_init(&v, expected, 2);
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a"));
|
||||
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // duplicate, no progress, no reset
|
||||
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test (échec attendu)**
|
||||
|
||||
Run: cible de test Unity du composant (`idf.py -C idf_zacus/components/local_puzzles/test build` via un projet de test, ou la cible `unity` du composant).
|
||||
Expected: FAIL — `seq_validator.h` introuvable.
|
||||
|
||||
- [ ] **Step 3: Écrire le header**
|
||||
|
||||
```c
|
||||
// seq_validator.h — ordered-scan validator (QR P3). Pure logic, no I/O.
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define SEQ_MAX_STEPS 16
|
||||
#define SEQ_MAX_LABEL 32
|
||||
|
||||
typedef struct {
|
||||
char expected[SEQ_MAX_STEPS][SEQ_MAX_LABEL];
|
||||
size_t count;
|
||||
size_t pos; // next index to match
|
||||
} seq_validator_t;
|
||||
|
||||
// expected: array of `count` C-strings (<= SEQ_MAX_STEPS, each < SEQ_MAX_LABEL).
|
||||
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count);
|
||||
|
||||
// Feed one scanned code. Returns true exactly when the full sequence is done.
|
||||
// A wrong code resets progress; a duplicate of the last matched code is ignored.
|
||||
bool seq_validator_feed(seq_validator_t *v, const char *code);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Écrire l'implémentation minimale**
|
||||
|
||||
```c
|
||||
// seq_validator.c
|
||||
#include "seq_validator.h"
|
||||
#include <string.h>
|
||||
|
||||
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count)
|
||||
{
|
||||
memset(v, 0, sizeof(*v));
|
||||
if (count > SEQ_MAX_STEPS) count = SEQ_MAX_STEPS;
|
||||
v->count = count;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
strncpy(v->expected[i], expected[i], SEQ_MAX_LABEL - 1);
|
||||
}
|
||||
}
|
||||
|
||||
bool seq_validator_feed(seq_validator_t *v, const char *code)
|
||||
{
|
||||
if (v->count == 0 || v->pos >= v->count) return false;
|
||||
// duplicate of the last matched code: ignore (no progress, no reset)
|
||||
if (v->pos > 0 && strncmp(code, v->expected[v->pos - 1], SEQ_MAX_LABEL) == 0)
|
||||
return false;
|
||||
if (strncmp(code, v->expected[v->pos], SEQ_MAX_LABEL) == 0) {
|
||||
v->pos++;
|
||||
return v->pos == v->count;
|
||||
}
|
||||
v->pos = 0; // wrong scan -> reset
|
||||
// allow the wrong scan to itself start a new run at index 0
|
||||
if (strncmp(code, v->expected[0], SEQ_MAX_LABEL) == 0) v->pos = 1;
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer le test (succès attendu)** — 3 cas `[seq]` verts.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/seq_validator.h \
|
||||
idf_zacus/components/local_puzzles/seq_validator.c \
|
||||
idf_zacus/components/local_puzzles/test/test_validators.c
|
||||
git commit -m "feat(local): QR sequence validator + tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Validateur de mélodie (logique pure, testable hôte)
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/local_puzzles/melody_validator.h`
|
||||
- Create: `idf_zacus/components/local_puzzles/melody_validator.c`
|
||||
- Modify: `idf_zacus/components/local_puzzles/test/test_validators.c` (ajouter des cas)
|
||||
|
||||
- [ ] **Step 1: Écrire le test qui échoue**
|
||||
|
||||
```c
|
||||
#include "melody_validator.h"
|
||||
|
||||
// Notes as MIDI numbers; tolerance in semitones. The player reproduces a melody;
|
||||
// the detector hands us one note at a time, we accept the sequence within tolerance.
|
||||
TEST_CASE("melody accepts the exact expected note sequence", "[melody]")
|
||||
{
|
||||
const int expected[] = {60, 62, 64, 65}; // do re mi fa
|
||||
melody_validator_t m;
|
||||
melody_validator_init(&m, expected, 4, /*tolerance=*/1);
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 62));
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 64));
|
||||
TEST_ASSERT_TRUE (melody_validator_feed(&m, 65));
|
||||
}
|
||||
|
||||
TEST_CASE("melody accepts notes within tolerance", "[melody]")
|
||||
{
|
||||
const int expected[] = {60, 62};
|
||||
melody_validator_t m;
|
||||
melody_validator_init(&m, expected, 2, 1);
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 61)); // 60 +/-1 ok
|
||||
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
|
||||
}
|
||||
|
||||
TEST_CASE("melody resets on an out-of-tolerance note", "[melody]")
|
||||
{
|
||||
const int expected[] = {60, 62};
|
||||
melody_validator_t m;
|
||||
melody_validator_init(&m, expected, 2, 1);
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 70)); // way off -> reset
|
||||
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
|
||||
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test (échec attendu)** — `melody_validator.h` introuvable.
|
||||
|
||||
- [ ] **Step 3: Écrire le header**
|
||||
|
||||
```c
|
||||
// melody_validator.h — note-sequence validator (P1). Pure logic, no I/O.
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define MELODY_MAX_NOTES 32
|
||||
|
||||
typedef struct {
|
||||
int expected[MELODY_MAX_NOTES]; // MIDI note numbers
|
||||
size_t count;
|
||||
int tolerance; // accepted deviation in semitones
|
||||
size_t pos;
|
||||
} melody_validator_t;
|
||||
|
||||
void melody_validator_init(melody_validator_t *m, const int *expected,
|
||||
size_t count, int tolerance);
|
||||
|
||||
// Feed one detected note (MIDI). True when the full melody is matched.
|
||||
bool melody_validator_feed(melody_validator_t *m, int note);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Écrire l'implémentation**
|
||||
|
||||
```c
|
||||
// melody_validator.c
|
||||
#include "melody_validator.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
void melody_validator_init(melody_validator_t *m, const int *expected,
|
||||
size_t count, int tolerance)
|
||||
{
|
||||
memset(m, 0, sizeof(*m));
|
||||
if (count > MELODY_MAX_NOTES) count = MELODY_MAX_NOTES;
|
||||
m->count = count;
|
||||
m->tolerance = tolerance;
|
||||
for (size_t i = 0; i < count; i++) m->expected[i] = expected[i];
|
||||
}
|
||||
|
||||
bool melody_validator_feed(melody_validator_t *m, int note)
|
||||
{
|
||||
if (m->count == 0 || m->pos >= m->count) return false;
|
||||
if (abs(note - m->expected[m->pos]) <= m->tolerance) {
|
||||
m->pos++;
|
||||
return m->pos == m->count;
|
||||
}
|
||||
// out of tolerance: reset, but let this note seed index 0 if it fits
|
||||
m->pos = 0;
|
||||
if (abs(note - m->expected[0]) <= m->tolerance) m->pos = 1;
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer le test (succès attendu)** — cas `[melody]` verts.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/melody_validator.h \
|
||||
idf_zacus/components/local_puzzles/melody_validator.c \
|
||||
idf_zacus/components/local_puzzles/test/test_validators.c
|
||||
git commit -m "feat(local): melody validator + tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fondation — agrégation « puzzle résolu » dans le master
|
||||
|
||||
**Pourquoi :** `idf_zacus` n'a aujourd'hui **aucun** chemin de réception d'énigme résolue (ni ESP-NOW, ni HTTP). `local_puzzle_report` n'a donc rien à mirrorer — on crée la fondation, que les énigmes locales **et** futures distantes utiliseront.
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/puzzle_state/include/puzzle_state.h`
|
||||
- Create: `idf_zacus/components/puzzle_state/puzzle_state.c`
|
||||
- Create: `idf_zacus/components/puzzle_state/CMakeLists.txt`
|
||||
- Test: `idf_zacus/components/puzzle_state/test/test_puzzle_state.c`
|
||||
|
||||
- [ ] **Step 1: Écrire le test qui échoue**
|
||||
|
||||
```c
|
||||
#include "unity.h"
|
||||
#include "puzzle_state.h"
|
||||
|
||||
TEST_CASE("assembles the code from reported fragments", "[pstate]")
|
||||
{
|
||||
puzzle_state_t s;
|
||||
puzzle_state_init(&s);
|
||||
puzzle_state_report(&s, /*id=*/1, (const uint8_t[]){1,2,0,0}, 2);
|
||||
puzzle_state_report(&s, /*id=*/3, (const uint8_t[]){3,4,0,0}, 2);
|
||||
char code[16];
|
||||
TEST_ASSERT_EQUAL_INT(4, puzzle_state_code(&s, code, sizeof(code)));
|
||||
TEST_ASSERT_EQUAL_STRING("1234", code);
|
||||
}
|
||||
|
||||
TEST_CASE("a puzzle reported twice does not duplicate its digits", "[pstate]")
|
||||
{
|
||||
puzzle_state_t s;
|
||||
puzzle_state_init(&s);
|
||||
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
|
||||
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
|
||||
char code[16];
|
||||
TEST_ASSERT_EQUAL_INT(2, puzzle_state_code(&s, code, sizeof(code)));
|
||||
TEST_ASSERT_EQUAL_STRING("12", code);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test (échec attendu)** — header introuvable.
|
||||
|
||||
- [ ] **Step 3: Écrire le header**
|
||||
|
||||
```c
|
||||
// puzzle_state.h — master-side aggregation of solved puzzles + assembled code.
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define PUZZLE_MAX_ID 8
|
||||
#define PUZZLE_MAX_FRAG 4
|
||||
|
||||
typedef struct {
|
||||
bool solved[PUZZLE_MAX_ID + 1];
|
||||
uint8_t frag[PUZZLE_MAX_ID + 1][PUZZLE_MAX_FRAG];
|
||||
uint8_t frag_len[PUZZLE_MAX_ID + 1];
|
||||
} puzzle_state_t;
|
||||
|
||||
void puzzle_state_init(puzzle_state_t *s);
|
||||
|
||||
// Record a solved puzzle. Idempotent per id. `len` <= PUZZLE_MAX_FRAG.
|
||||
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
|
||||
const uint8_t *fragment, uint8_t len);
|
||||
|
||||
// Write the assembled code (digits of all solved puzzles, by ascending id)
|
||||
// as a NUL-terminated string. Returns the number of digits written.
|
||||
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Écrire l'implémentation**
|
||||
|
||||
```c
|
||||
// puzzle_state.c
|
||||
#include "puzzle_state.h"
|
||||
#include <string.h>
|
||||
|
||||
void puzzle_state_init(puzzle_state_t *s) { memset(s, 0, sizeof(*s)); }
|
||||
|
||||
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
|
||||
const uint8_t *fragment, uint8_t len)
|
||||
{
|
||||
if (id == 0 || id > PUZZLE_MAX_ID) return;
|
||||
if (len > PUZZLE_MAX_FRAG) len = PUZZLE_MAX_FRAG;
|
||||
if (s->solved[id]) return; // idempotent
|
||||
s->solved[id] = true;
|
||||
s->frag_len[id] = len;
|
||||
memcpy(s->frag[id], fragment, len);
|
||||
}
|
||||
|
||||
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap)
|
||||
{
|
||||
int n = 0;
|
||||
for (uint8_t id = 1; id <= PUZZLE_MAX_ID; id++) {
|
||||
if (!s->solved[id]) continue;
|
||||
for (uint8_t k = 0; k < s->frag_len[id] && (size_t)(n + 1) < cap; k++)
|
||||
out[n++] = (char)('0' + (s->frag[id][k] % 10));
|
||||
}
|
||||
if ((size_t)n < cap) out[n] = '\0';
|
||||
return n;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Écrire le CMakeLists du composant**
|
||||
|
||||
```cmake
|
||||
idf_component_register(
|
||||
SRCS "puzzle_state.c"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Lancer le test (succès attendu)** — cas `[pstate]` verts.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/puzzle_state/
|
||||
git commit -m "feat(master): puzzle-solved aggregation + code assembly"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `mic_broker` + refacto `voice_pipeline`
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/local_puzzles/mic_broker.h`
|
||||
- Create: `idf_zacus/components/local_puzzles/mic_broker.c`
|
||||
- Modify: `idf_zacus/components/voice_pipeline/voice_pipeline.c` (i2s_setup + capture_task)
|
||||
|
||||
- [ ] **Step 1: Écrire le header du broker**
|
||||
|
||||
```c
|
||||
// mic_broker.h — single owner of the I2S RX mic; routes frames to one
|
||||
// active consumer chosen by mode. No frame is delivered in MIC_IDLE.
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef enum { MIC_IDLE, MIC_NPC_LISTEN, MIC_P1_SOUND } mic_mode_t;
|
||||
|
||||
// cb receives PCM16 mono frames (count = samples) when its mode is active.
|
||||
typedef void (*mic_frame_cb_t)(const int16_t *pcm, size_t samples, void *ctx);
|
||||
|
||||
esp_err_t mic_broker_init(int bclk_pin, int ws_pin, int din_pin, int sample_rate_hz);
|
||||
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx);
|
||||
void mic_broker_set_mode(mic_mode_t mode);
|
||||
mic_mode_t mic_broker_mode(void);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire l'implémentation du broker**
|
||||
|
||||
```c
|
||||
// mic_broker.c
|
||||
#include "mic_broker.h"
|
||||
#include "driver/i2s_std.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define MB_FRAME 320 // 20 ms @ 16 kHz
|
||||
|
||||
static i2s_chan_handle_t s_rx;
|
||||
static volatile mic_mode_t s_mode = MIC_IDLE;
|
||||
static struct { mic_frame_cb_t cb; void *ctx; } s_consumers[3];
|
||||
|
||||
static void capture_task(void *arg) {
|
||||
int16_t buf[MB_FRAME];
|
||||
size_t got;
|
||||
for (;;) {
|
||||
if (i2s_channel_read(s_rx, buf, sizeof(buf), &got, portMAX_DELAY) != ESP_OK) continue;
|
||||
mic_mode_t m = s_mode;
|
||||
if (m != MIC_IDLE && s_consumers[m].cb)
|
||||
s_consumers[m].cb(buf, got / sizeof(int16_t), s_consumers[m].ctx);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t mic_broker_init(int bclk, int ws, int din, int sr) {
|
||||
i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
|
||||
esp_err_t e = i2s_new_channel(&cc, NULL, &s_rx);
|
||||
if (e != ESP_OK) return e;
|
||||
i2s_std_config_t std = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sr),
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg = { .bclk = bclk, .ws = ws, .din = din, .mclk = I2S_GPIO_UNUSED,
|
||||
.dout = I2S_GPIO_UNUSED, .invert_flags = {0} },
|
||||
};
|
||||
e = i2s_channel_init_std_mode(s_rx, &std);
|
||||
if (e != ESP_OK) return e;
|
||||
e = i2s_channel_enable(s_rx);
|
||||
if (e != ESP_OK) return e;
|
||||
xTaskCreate(capture_task, "mic_broker", 4096, NULL, 5, NULL);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx) {
|
||||
if (mode == MIC_IDLE) return;
|
||||
s_consumers[mode].cb = cb; s_consumers[mode].ctx = ctx;
|
||||
}
|
||||
void mic_broker_set_mode(mic_mode_t mode) { s_mode = mode; }
|
||||
mic_mode_t mic_broker_mode(void) { return s_mode; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Refacto `voice_pipeline` pour consommer le broker**
|
||||
|
||||
Dans `voice_pipeline.c`, remplacer l'init I2S RX locale (`i2s_setup`, `rx_chan`, lecture dans `capture_task`) par : `mic_broker_init(cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin, 16000)` au démarrage, puis `mic_broker_register(MIC_NPC_LISTEN, on_npc_frame, &s_pipe)` où `on_npc_frame` contient le corps actuel de la boucle de capture (feed AFE). Conserver l'I2S TX (haut-parleur) inchangé. Au passage en écoute NPC : `mic_broker_set_mode(MIC_NPC_LISTEN)`.
|
||||
|
||||
- [ ] **Step 4: Build + smoke régression**
|
||||
|
||||
Run: `idf.py -C idf_zacus build && idf.py -C idf_zacus -p /dev/cu.usbmodem5AB90753301 flash monitor`
|
||||
Expected: NPC vocal toujours fonctionnel (wakeword + stream WS), aucun log d'erreur I2S.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/mic_broker.h \
|
||||
idf_zacus/components/local_puzzles/mic_broker.c \
|
||||
idf_zacus/components/voice_pipeline/voice_pipeline.c
|
||||
git commit -m "feat(local): mic broker; voice_pipeline pulls from it"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `qr_puzzle` (caméra OV2640 + quirc + seq_validator)
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/local_puzzles/qr_puzzle.c`
|
||||
- Create: `idf_zacus/components/local_puzzles/include/qr_puzzle.h`
|
||||
- Create/Modify: `idf_zacus/components/local_puzzles/idf_component.yml`
|
||||
|
||||
- [ ] **Step 1: Déclarer les deps managées**
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
espressif/esp32-camera:
|
||||
version: ">=2.0.0"
|
||||
espressif/quirc: # si absent du registre, vendorer quirc (note ci-dessous)
|
||||
version: "*"
|
||||
```
|
||||
|
||||
> Si `espressif/quirc` n'existe pas au registre : vendorer `quirc` (lib C, ~5 fichiers) sous `idf_zacus/components/quirc/` avec un `CMakeLists.txt` minimal `idf_component_register(SRCS quirc.c decode.c identify.c version_db.c INCLUDE_DIRS .)`.
|
||||
|
||||
- [ ] **Step 2: Écrire `qr_puzzle.h`**
|
||||
|
||||
```c
|
||||
// qr_puzzle.h
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef void (*qr_solved_cb_t)(void);
|
||||
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb);
|
||||
void qr_puzzle_stop(void);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Écrire `qr_puzzle.c`**
|
||||
|
||||
```c
|
||||
// qr_puzzle.c — camera capture + quirc decode; validates QR order.
|
||||
#include "qr_puzzle.h"
|
||||
#include "seq_validator.h"
|
||||
#include "board_pins_mediakit.h"
|
||||
#include "esp_camera.h"
|
||||
#include "quirc.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include <string.h>
|
||||
|
||||
#define TAG "qr_puzzle"
|
||||
static seq_validator_t s_seq;
|
||||
static qr_solved_cb_t s_cb;
|
||||
static TaskHandle_t s_task;
|
||||
static volatile bool s_run;
|
||||
|
||||
static camera_config_t cam_cfg(void) {
|
||||
camera_config_t c = {
|
||||
.pin_pwdn = CAM_PIN_PWDN, .pin_reset = CAM_PIN_RESET, .pin_xclk = CAM_PIN_XCLK,
|
||||
.pin_sccb_sda = CAM_PIN_SIOD, .pin_sccb_scl = CAM_PIN_SIOC,
|
||||
.pin_d7 = CAM_PIN_D7, .pin_d6 = CAM_PIN_D6, .pin_d5 = CAM_PIN_D5, .pin_d4 = CAM_PIN_D4,
|
||||
.pin_d3 = CAM_PIN_D3, .pin_d2 = CAM_PIN_D2, .pin_d1 = CAM_PIN_D1, .pin_d0 = CAM_PIN_D0,
|
||||
.pin_vsync = CAM_PIN_VSYNC, .pin_href = CAM_PIN_HREF, .pin_pclk = CAM_PIN_PCLK,
|
||||
.xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0,
|
||||
.pixel_format = PIXFORMAT_GRAYSCALE, .frame_size = FRAMESIZE_QVGA,
|
||||
.fb_count = 1, .fb_location = CAMERA_FB_IN_PSRAM, .grab_mode = CAMERA_GRAB_LATEST,
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
static void scan_task(void *arg) {
|
||||
struct quirc *q = quirc_new();
|
||||
quirc_resize(q, 320, 240);
|
||||
while (s_run) {
|
||||
camera_fb_t *fb = esp_camera_fb_get();
|
||||
if (!fb) { vTaskDelay(pdMS_TO_TICKS(50)); continue; }
|
||||
uint8_t *img = quirc_begin(q, NULL, NULL);
|
||||
memcpy(img, fb->buf, 320 * 240);
|
||||
quirc_end(q);
|
||||
int n = quirc_count(q);
|
||||
for (int i = 0; i < n; i++) {
|
||||
struct quirc_code code; struct quirc_data data;
|
||||
quirc_extract(q, i, &code);
|
||||
if (quirc_decode(&code, &data) == 0) {
|
||||
if (seq_validator_feed(&s_seq, (const char *)data.payload)) {
|
||||
if (s_cb) s_cb();
|
||||
s_run = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
esp_camera_fb_return(fb);
|
||||
vTaskDelay(pdMS_TO_TICKS(30));
|
||||
}
|
||||
quirc_destroy(q);
|
||||
esp_camera_deinit(); // free PSRAM + power down (battery)
|
||||
s_task = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb) {
|
||||
if (s_task) return ESP_ERR_INVALID_STATE;
|
||||
camera_config_t cfg = cam_cfg();
|
||||
esp_err_t e = esp_camera_init(&cfg);
|
||||
if (e != ESP_OK) { ESP_LOGE(TAG, "camera init: 0x%x", e); return e; }
|
||||
seq_validator_init(&s_seq, expected, count);
|
||||
s_cb = cb; s_run = true;
|
||||
xTaskCreate(scan_task, "qr_scan", 8192, NULL, 5, &s_task);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void qr_puzzle_stop(void) { s_run = false; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Smoke hardware**
|
||||
|
||||
Armer P3 (Task 7) avec un set de QR connus, présenter les QR dans l'ordre devant la caméra.
|
||||
Expected: log `qr_puzzle` reconnaît chaque code ; au dernier, le callback se déclenche.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/qr_puzzle.c \
|
||||
idf_zacus/components/local_puzzles/include/qr_puzzle.h \
|
||||
idf_zacus/components/local_puzzles/idf_component.yml
|
||||
git commit -m "feat(local): QR puzzle via camera + quirc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `sound_puzzle` (frames micro + détection notes + melody_validator)
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/local_puzzles/sound_puzzle.c`
|
||||
- Create: `idf_zacus/components/local_puzzles/include/sound_puzzle.h`
|
||||
|
||||
- [ ] **Step 1: Écrire `sound_puzzle.h`**
|
||||
|
||||
```c
|
||||
// sound_puzzle.h
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
typedef void (*sound_solved_cb_t)(void);
|
||||
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb);
|
||||
void sound_puzzle_stop(void);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire `sound_puzzle.c`**
|
||||
|
||||
```c
|
||||
// sound_puzzle.c — consumes MIC_P1_SOUND frames, estimates note, validates melody.
|
||||
#include "sound_puzzle.h"
|
||||
#include "melody_validator.h"
|
||||
#include "mic_broker.h"
|
||||
#include <math.h>
|
||||
|
||||
static melody_validator_t s_mel;
|
||||
static sound_solved_cb_t s_cb;
|
||||
static int s_last_note = -1000;
|
||||
|
||||
// Crude pitch: zero-crossing rate -> fundamental -> MIDI. Replaced by an
|
||||
// autocorrelation estimator if accuracy is insufficient (documented limit).
|
||||
static int frame_to_midi(const int16_t *pcm, size_t n, int sr) {
|
||||
size_t zc = 0;
|
||||
for (size_t i = 1; i < n; i++) if ((pcm[i-1] < 0) != (pcm[i] < 0)) zc++;
|
||||
if (zc < 2) return -1000; // silence
|
||||
double freq = (double)zc * sr / (2.0 * n);
|
||||
if (freq < 80 || freq > 2000) return -1000;
|
||||
return (int)lround(69 + 12 * log2(freq / 440.0));
|
||||
}
|
||||
|
||||
static void on_frame(const int16_t *pcm, size_t n, void *ctx) {
|
||||
int note = frame_to_midi(pcm, n, 16000);
|
||||
if (note <= -1000) { s_last_note = -1000; return; }
|
||||
if (note == s_last_note) return; // debounce sustained note
|
||||
s_last_note = note;
|
||||
if (melody_validator_feed(&s_mel, note)) { if (s_cb) s_cb(); }
|
||||
}
|
||||
|
||||
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb) {
|
||||
melody_validator_init(&s_mel, expected, count, tol);
|
||||
s_cb = cb; s_last_note = -1000;
|
||||
mic_broker_register(MIC_P1_SOUND, on_frame, NULL);
|
||||
mic_broker_set_mode(MIC_P1_SOUND);
|
||||
}
|
||||
void sound_puzzle_stop(void) { mic_broker_set_mode(MIC_IDLE); }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke hardware**
|
||||
|
||||
Armer P1, jouer la mélodie attendue (instrument/voix) devant le micro.
|
||||
Expected: les notes détectées progressent ; à la dernière, le callback se déclenche. Documenter dans le commit la limite connue de l'estimateur (zero-crossing) si la robustesse est insuffisante (piste : autocorrélation / `esp-dsp` FFT).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/sound_puzzle.c \
|
||||
idf_zacus/components/local_puzzles/include/sound_puzzle.h
|
||||
git commit -m "feat(local): sound puzzle via mic broker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Câblage `local_puzzles` + flag config local|remote + main.c
|
||||
|
||||
**Files:**
|
||||
- Create: `idf_zacus/components/local_puzzles/local_puzzles.c`
|
||||
- Create: `idf_zacus/components/local_puzzles/include/local_puzzles.h`
|
||||
- Create: `idf_zacus/components/local_puzzles/CMakeLists.txt`
|
||||
- Modify: `idf_zacus/main/main.c`
|
||||
|
||||
- [ ] **Step 1: Écrire `local_puzzles.h`**
|
||||
|
||||
```c
|
||||
// local_puzzles.h
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "puzzle_state.h"
|
||||
|
||||
void local_puzzles_init(puzzle_state_t *state);
|
||||
esp_err_t local_puzzles_arm_qr(const char *const *expected, size_t count);
|
||||
void local_puzzles_arm_sound(const int *expected, size_t count, int tol);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire `local_puzzles.c`**
|
||||
|
||||
```c
|
||||
// local_puzzles.c — wires QR/sound to puzzle_state and arms by puzzle id.
|
||||
#include "local_puzzles.h"
|
||||
#include "qr_puzzle.h"
|
||||
#include "sound_puzzle.h"
|
||||
|
||||
static puzzle_state_t *s_state;
|
||||
|
||||
// Code-fragment slots per scenario (placeholder values; the scenario engine
|
||||
// will supply real fragments in a later iteration).
|
||||
static void on_qr_solved(void) { puzzle_state_report(s_state, 3, (const uint8_t[]){5,0,0,0}, 1); }
|
||||
static void on_sound_solved(void) { puzzle_state_report(s_state, 1, (const uint8_t[]){1,2,0,0}, 2); }
|
||||
|
||||
void local_puzzles_init(puzzle_state_t *state) { s_state = state; }
|
||||
|
||||
esp_err_t local_puzzles_arm_qr(const char *const *expected, size_t count) {
|
||||
return qr_puzzle_start(expected, count, on_qr_solved);
|
||||
}
|
||||
void local_puzzles_arm_sound(const int *expected, size_t count, int tol) {
|
||||
sound_puzzle_start(expected, count, tol, on_sound_solved);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Écrire le `CMakeLists.txt` du composant**
|
||||
|
||||
```cmake
|
||||
idf_component_register(
|
||||
SRCS "local_puzzles.c" "mic_broker.c" "qr_puzzle.c" "sound_puzzle.c"
|
||||
"seq_validator.c" "melody_validator.c"
|
||||
INCLUDE_DIRS "include" "."
|
||||
PRIV_REQUIRES driver esp_timer puzzle_state
|
||||
REQUIRES esp_camera
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Initialiser dans `main.c`**
|
||||
|
||||
Après l'init Wi-Fi / `game_endpoint`, ajouter : `static puzzle_state_t s_pstate;` global, `puzzle_state_init(&s_pstate);`, `local_puzzles_init(&s_pstate);`, et `mic_broker_init(14, 15, 22, 16000);` (pins micro du `voice_pipeline` — à aligner avec Task 0). L'armement de P1/P3 est déclenché par l'étape de scénario active (gating via le moteur de scénario existant) ; en V1, appel direct `local_puzzles_arm_qr(...)` / `local_puzzles_arm_sound(...)` lors de la phase correspondante avec les valeurs du scénario chargé.
|
||||
|
||||
- [ ] **Step 5: Build + flash + smoke complet**
|
||||
|
||||
Run: `idf.py -C idf_zacus -p /dev/cu.usbmodem5AB90753301 flash monitor`
|
||||
Expected: build vert, P3 puis P1 résolus localement déclenchent `puzzle_state_report` et le code s'assemble (`puzzle_state_code`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add idf_zacus/components/local_puzzles/local_puzzles.c \
|
||||
idf_zacus/components/local_puzzles/include/local_puzzles.h \
|
||||
idf_zacus/components/local_puzzles/CMakeLists.txt \
|
||||
idf_zacus/main/main.c
|
||||
git commit -m "feat(local): wire local puzzles into the master"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (couverture spec)
|
||||
|
||||
- mic_broker + contention → Task 4 ✓
|
||||
- qr_puzzle (caméra+quirc+ordre) → Task 5 ✓
|
||||
- sound_puzzle (mélodie) → Task 6 ✓
|
||||
- local_puzzle_report → fondu dans `puzzle_state` (Task 3) + callbacks `local_puzzles` (Task 7) ✓
|
||||
- flag local|remote → Task 7 Step 4 (gating scénario) ✓ ; config persistante `boards.yaml` = extension future, notée
|
||||
- validateurs testables hôte → Tasks 1, 2 ✓
|
||||
- pin-map / conflit GPIO → Task 0 (bloquant) ✓
|
||||
- **Écart spec assumé** : le spec supposait un chemin `MSG_PUZZLE_SOLVED` existant ; il n'existe pas dans `idf_zacus` → fondation `puzzle_state` ajoutée (Task 3). Le repli ESP-NOW « si le nœud existe » reste théorique tant que le master ne reçoit pas d'ESP-NOW puzzle — à traiter dans un plan ultérieur si P5/P6/P7 doivent aussi remonter au master.
|
||||
|
||||
## Inconnues à lever en exécution
|
||||
|
||||
1. Pins réelles caméra/micro/HP du Media Kit v1.2 (Task 0, bloquant).
|
||||
2. Présence de `espressif/quirc` au registre, sinon vendoring (Task 5 Step 1).
|
||||
3. Robustesse de l'estimateur de hauteur zero-crossing vs autocorrélation/FFT `esp-dsp` (Task 6 Step 3).
|
||||
4. Fragments de code réels par énigme (actuellement placeholders dans `local_puzzles.c`) — fournis par le moteur de scénario.
|
||||
@@ -0,0 +1,584 @@
|
||||
# Atelier Refonte « Workbench Console » Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refondre l'UI de `apps/atelier` : retirer la simulation 3D, appliquer un thème néo-rétro « Workbench » (palette firmware), et remplacer la colonne 3D par un Banc d'essai (aperçu écran device fidèle + vue flux du scénario, split vertical). Blockly est conservé.
|
||||
|
||||
**Architecture:** Trois phases séquentielles — A) soustraction 3D (build doit rester vert), B) thème Workbench (CSS + thème Blockly + re-skin), C) Banc d'essai (`WorkbenchPane` = `DeviceScreen` + `FlowGraph` pilotés par un `selectionStore`, lisant l'IR de `runtimeStore`).
|
||||
|
||||
**Tech Stack:** React 19, TypeScript (strict, `exactOptionalPropertyTypes`), Zustand, react-resizable-panels, Blockly, Vite. Verif par tâche : `pnpm exec tsc --noEmit` + `pnpm exec vite build` verts (pas de tests unitaires pour le rendu visuel ; les hooks e2e existants sont mis à jour en phase A).
|
||||
|
||||
**Réfs :** spec `docs/superpowers/specs/2026-06-13-atelier-refonte-workbench-design.md` ; types IR `frontend-v3/packages/scenario-engine/src/runtime-ir.ts` (`RuntimeIr.steps: RuntimeStep[]`, `RuntimeStep.id`, `RuntimeStep.scene?: StepScene{title,subtitle,symbol,effect}`, `RuntimeTransition.target_step_id`).
|
||||
|
||||
**Convention commit :** `git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit`. Toutes les commandes depuis `frontend-v3/apps/atelier` sauf indication.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Fichier | Responsabilité | Phase |
|
||||
|---|---|---|
|
||||
| `src/components/StagePane.tsx` + `src/scene/*` + `src/puzzles/*` + `src/modes/*` + `src/stores/simStore.ts` | **supprimés** (3D) | A |
|
||||
| `src/components/Layout.tsx` | layout : Blockly + `WorkbenchPane` + console ; toggle `⌘B` du banc | A, C |
|
||||
| `src/components/ConsolePane.tsx` | console validation seule (retrait des onglets de modes) | A |
|
||||
| `src/App.tsx` | hooks dev sans `sim` | A |
|
||||
| `vite.config.ts` | retrait `manualChunks` three/r3f | A |
|
||||
| `package.json` | retrait deps three/r3f | A |
|
||||
| `src/theme/workbench.ts` | constantes palette/fonts (source unique) | B |
|
||||
| `src/main.css` | shell + chrome Workbench | B |
|
||||
| `src/components/BlocklyWorkspace.tsx` | thème Blockly Workbench | B |
|
||||
| `src/components/MenuBar.tsx`, panneaux flottants | re-skin | B |
|
||||
| `src/lib/screenSpec.ts` | déjà présent (P3a) — source des couleurs/limites écran | B (réf) |
|
||||
| `src/components/DeviceScreenView.tsx` | rendu LCD fidèle réutilisable (extrait de `ScreenEditorPanel`) | C |
|
||||
| `src/stores/selectionStore.ts` | `selectedStepId` | C |
|
||||
| `src/lib/irSteps.ts` | parse `currentIr` → `{steps, edges}` | C |
|
||||
| `src/components/FlowGraph.tsx` | graphe d'étapes cliquable | C |
|
||||
| `src/components/DeviceScreen.tsx` | écran de l'étape sélectionnée | C |
|
||||
| `src/components/WorkbenchPane.tsx` | split vertical Écran/Flux | C |
|
||||
|
||||
---
|
||||
|
||||
## PHASE A — Retrait de la 3D
|
||||
|
||||
### Task A1: Supprimer les fichiers 3D et le simStore
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/components/StagePane.tsx`, `src/scene/` (NpcBubble, PuzzleStation, PuzzleStations, Room, RoomScene, RtcPhone), `src/puzzles/` (P1Sound, P5Morse, P6Symbols, P7Coffre), `src/modes/` (DemoMode, SandboxMode, TestMode), `src/stores/simStore.ts`
|
||||
|
||||
- [ ] **Step 1: Supprimer les répertoires/fichiers 3D**
|
||||
|
||||
```bash
|
||||
cd frontend-v3/apps/atelier
|
||||
git rm -r src/scene src/puzzles src/modes src/components/StagePane.tsx src/stores/simStore.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lister les imports résiduels à corriger**
|
||||
|
||||
Run: `grep -rnE "simStore|StagePane|/scene/|/puzzles/|/modes/" src/ || echo "AUCUN"`
|
||||
Expected: occurrences dans `Layout.tsx`, `App.tsx`, `ConsolePane.tsx`, `StagePane`-consumers — traitées aux tâches suivantes. (Ne pas committer encore : le build est cassé jusqu'à A4.)
|
||||
|
||||
### Task A2: Layout sans StagePane (placeholder banc en phase A)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/Layout.tsx`
|
||||
|
||||
- [ ] **Step 1: Remplacer l'import + le panneau droit**
|
||||
|
||||
Dans `Layout.tsx`, retirer `import { StagePane } from './StagePane.js';` et remplacer le `<Panel ...><StagePane /></Panel>` droit par un placeholder neutre (le vrai `WorkbenchPane` arrive en phase C) :
|
||||
|
||||
```tsx
|
||||
// en tête : remplacer l'import StagePane par :
|
||||
// (phase C remplacera ce placeholder par WorkbenchPane)
|
||||
|
||||
// dans le PanelGroup horizontal, panneau droit :
|
||||
<Panel
|
||||
ref={stageRef}
|
||||
defaultSize={45}
|
||||
minSize={20}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
>
|
||||
<div className="atelier-pane" style={{ display: 'grid', placeItems: 'center', color: '#9a9a9d' }}>
|
||||
Banc d'essai (à venir)
|
||||
</div>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
Garder le toggle `⌘B` et `stageRef` inchangés (ils piloteront le banc).
|
||||
|
||||
- [ ] **Step 2: Vérifier qu'aucune réf StagePane ne subsiste dans Layout**
|
||||
|
||||
Run: `grep -n "StagePane" src/components/Layout.tsx || echo "OK"`
|
||||
Expected: `OK`
|
||||
|
||||
### Task A3: ConsolePane sans onglets de modes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ConsolePane.tsx`
|
||||
|
||||
- [ ] **Step 1: Inspecter les dépendances sim de la console**
|
||||
|
||||
Run: `grep -nE "simStore|mode|Mode|SandboxMode|DemoMode|TestMode" src/components/ConsolePane.tsx`
|
||||
Expected: identifie les imports/usages liés aux modes.
|
||||
|
||||
- [ ] **Step 2: Retirer l'état mode + l'onglet de modes, garder la validation**
|
||||
|
||||
Retirer de `ConsolePane.tsx` tout import de `simStore`/`modes/*` et le sélecteur d'onglets de modes. Conserver le rendu de la liste de validation (entries de `validationStore`). Le composant ne garde qu'un titre « Validation » + la liste des entrées. Remplacer toute lecture `simStore.getState().mode` par rien (supprimer la branche).
|
||||
|
||||
- [ ] **Step 3: Vérifier**
|
||||
|
||||
Run: `grep -nE "simStore|/modes/" src/components/ConsolePane.tsx || echo "OK"`
|
||||
Expected: `OK`
|
||||
|
||||
### Task A4: App.tsx — hooks dev sans `sim`, retrait deps, build vert
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`, `vite.config.ts`, `package.json`
|
||||
|
||||
- [ ] **Step 1: Retirer `sim` des hooks dev de App.tsx**
|
||||
|
||||
Dans `src/App.tsx`, retirer `import { useSimStore } from './stores/simStore.js';` et la clé `sim: useSimStore,` de l'objet `window.__atelierStores`. Laisser `editor`, `runtime`, `validation`.
|
||||
|
||||
- [ ] **Step 2: Retirer le manualChunks three/r3f de vite.config.ts**
|
||||
|
||||
Run: `grep -nE "three|fiber|drei|r3f" vite.config.ts`
|
||||
Puis supprimer les entrées `manualChunks` correspondantes (three / r3f) du `build.rollupOptions.output`. Garder le chunk blockly.
|
||||
|
||||
- [ ] **Step 3: Retirer les deps 3D de package.json**
|
||||
|
||||
```bash
|
||||
cd frontend-v3/apps/atelier
|
||||
pnpm remove three @types/three @react-three/fiber @react-three/drei
|
||||
```
|
||||
Expected: met à jour `package.json` + lockfile.
|
||||
|
||||
- [ ] **Step 4: Typecheck + build verts**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS, aucun import three/r3f restant. Si erreur « cannot find module ./stores/simStore » ou scene/puzzles/modes → corriger le consommateur résiduel signalé.
|
||||
|
||||
- [ ] **Step 5: Nettoyer les specs e2e pilotant la sim**
|
||||
|
||||
Run: `grep -rnlE "__atelierStores.*sim|simStore|SandboxMode|DemoMode|TestMode" ../../../e2e ../../e2e 2>/dev/null || echo "AUCUN e2e sim"`
|
||||
Pour chaque spec trouvée : retirer/mettre à jour les assertions sur `sim`/modes (la sim n'existe plus). Si aucune, passer.
|
||||
|
||||
- [ ] **Step 6: Commit phase A**
|
||||
|
||||
```bash
|
||||
cd frontend-v3/apps/atelier
|
||||
git add -A && cd ../../.. && git add -A
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "refactor(atelier): remove 3D simulation (StagePane/scene/puzzles/modes/simStore, three deps)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE B — Thème Workbench
|
||||
|
||||
### Task B1: Module de thème (source unique)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/theme/workbench.ts`
|
||||
|
||||
- [ ] **Step 1: Créer les constantes de thème**
|
||||
|
||||
```ts
|
||||
// src/theme/workbench.ts
|
||||
// Palette néo-rétro « Workbench » — alignée sur ESP32_ZACUS display_ui.cpp
|
||||
// (COL_BG/COL_CODE/COL_VALUE/COL_LABEL) et lib/screenSpec.ts.
|
||||
export const WB = {
|
||||
bg: '#0a1722', // surface profonde (dérivée du bleu Workbench assombri)
|
||||
panel: '#0f2233', // panneaux
|
||||
panelBorder: '#1c3b52',
|
||||
workbenchBlue: '#0055AA', // COL_BG firmware (accents/écran)
|
||||
orange: '#FF8800', // COL_CODE — accent principal
|
||||
value: '#FFFFFF', // COL_VALUE
|
||||
label: '#AAAAAA', // COL_LABEL
|
||||
led: { ok: '#34d399', warn: '#fbbf24', err: '#fca5a5', off: '#3b4a57' },
|
||||
fontTitle: "'Orbitron','Arial Narrow',sans-serif",
|
||||
fontMono: "'IBM Plex Mono',ui-monospace,monospace",
|
||||
} as const;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: PASS.
|
||||
|
||||
### Task B2: Polices locales + shell CSS Workbench
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main.css`
|
||||
|
||||
- [ ] **Step 1: Charger Orbitron + IBM Plex Mono**
|
||||
|
||||
Les polices sont déjà bundlées par le firmware mais pas le front. Utiliser `@fontsource` (offline, pas de CDN) :
|
||||
|
||||
```bash
|
||||
cd frontend-v3/apps/atelier
|
||||
pnpm add @fontsource/orbitron @fontsource/ibm-plex-mono
|
||||
```
|
||||
Puis en tête de `src/main.css` :
|
||||
```css
|
||||
@import '@fontsource/orbitron/400.css';
|
||||
@import '@fontsource/orbitron/700.css';
|
||||
@import '@fontsource/ibm-plex-mono/400.css';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Appliquer le thème au shell**
|
||||
|
||||
Dans `src/main.css`, mettre à jour `.atelier-shell`, `.atelier-pane`, `.atelier-resizer*` :
|
||||
```css
|
||||
.atelier-shell { background: #0a1722; color: #e8eef3; font-family: 'IBM Plex Mono',ui-monospace,monospace; }
|
||||
.atelier-pane { background: #0f2233; border: 1px solid #1c3b52; }
|
||||
.atelier-resizer { background: #1c3b52; }
|
||||
.atelier-resizer:hover { background: #FF8800; }
|
||||
/* légère grille de fond façon plan de travail */
|
||||
.atelier-shell::before {
|
||||
content: ''; position: fixed; inset: 0; pointer-events: none; opacity: 0.05;
|
||||
background-image: linear-gradient(#FF8800 1px,transparent 1px),linear-gradient(90deg,#FF8800 1px,transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build vert**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS (les fonts apparaissent comme assets).
|
||||
|
||||
### Task B3: Barre-titre labo (MenuBar)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/MenuBar.tsx`
|
||||
|
||||
- [ ] **Step 1: Re-skin de la barre**
|
||||
|
||||
Ajouter à gauche un titre `▦ ZACUS · ATELIER` en `WB.fontTitle`, couleur `WB.orange`, letter-spacing 2px ; fond `WB.panel`, bordure basse `WB.panelBorder`. Conserver toutes les actions existantes (ouvrir/charger/enregistrer/partager) — seulement restyler (boutons : fond `WB.panel`, bordure `WB.panelBorder`, texte `#e8eef3`, hover bordure `WB.orange`). Importer `WB` depuis `../theme/workbench.js`.
|
||||
|
||||
- [ ] **Step 2: Build vert**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS.
|
||||
|
||||
### Task B4: Thème Blockly Workbench
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/BlocklyWorkspace.tsx`
|
||||
|
||||
- [ ] **Step 1: Définir + appliquer un thème Blockly**
|
||||
|
||||
Avant `Blockly.inject`, créer un thème et le passer dans les options :
|
||||
```ts
|
||||
import * as Blockly from 'blockly';
|
||||
const workbenchTheme = Blockly.Theme.defineTheme('zacus-workbench', {
|
||||
name: 'zacus-workbench',
|
||||
base: Blockly.Themes.Classic,
|
||||
componentStyles: {
|
||||
workspaceBackgroundColour: '#0a1722',
|
||||
toolboxBackgroundColour: '#0f2233',
|
||||
toolboxForegroundColour: '#e8eef3',
|
||||
flyoutBackgroundColour: '#0f2233',
|
||||
flyoutForegroundColour: '#AAAAAA',
|
||||
scrollbarColour: '#1c3b52',
|
||||
insertionMarkerColour: '#FF8800',
|
||||
gridColour: '#1c3b52',
|
||||
},
|
||||
fontStyle: { family: "'IBM Plex Mono',monospace", size: 11 },
|
||||
});
|
||||
// dans les options d'inject : theme: workbenchTheme, grid: { spacing: 24, colour: '#13283a', snap: true }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build vert + sanity**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS. (Vérif visuelle ultérieure.)
|
||||
|
||||
- [ ] **Step 3: Commit phase B**
|
||||
|
||||
```bash
|
||||
cd ../../.. && git add -A
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(atelier): Workbench theme (palette firmware, Orbitron/IBM Plex Mono, Blockly theme)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE C — Banc d'essai (Écran + Flux)
|
||||
|
||||
### Task C1: Extraire le rendu LCD fidèle réutilisable
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/DeviceScreenView.tsx`
|
||||
- Modify: `src/components/ScreenEditorPanel.tsx` (importer la vue partagée)
|
||||
|
||||
- [ ] **Step 1: Créer `DeviceScreenView` à partir du `ScreenPreview` de P3a**
|
||||
|
||||
Déplacer le composant `ScreenPreview` (et `symbolAnimation`) de `ScreenEditorPanel.tsx` vers un fichier partagé, paramétré par l'échelle :
|
||||
|
||||
```tsx
|
||||
// src/components/DeviceScreenView.tsx
|
||||
import { SCREEN, type SceneDraft, type ScreenEffect } from '../lib/screenSpec.js';
|
||||
|
||||
function symbolAnimation(effect: ScreenEffect): string | undefined {
|
||||
if (effect === 'pulse') return 'zacus-pulse 2.4s ease-in-out infinite';
|
||||
if (effect === 'glitch') return 'zacus-glitch 0.45s steps(2) infinite';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function DeviceScreenView({ scene, scale = 0.66 }: { scene: SceneDraft; scale?: number }) {
|
||||
return (
|
||||
<div style={{ width: SCREEN.width * scale, height: SCREEN.height * scale, alignSelf: 'center' }}>
|
||||
<div style={{
|
||||
width: SCREEN.width, height: SCREEN.height, transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||
background: SCREEN.bg, position: 'relative', borderRadius: 6, overflow: 'hidden',
|
||||
boxShadow: 'inset 0 0 0 2px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 24, left: 12, right: 12, textAlign: 'center',
|
||||
color: SCREEN.title.color, font: `700 40px ${SCREEN.title.font}`, letterSpacing: 2,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{scene.title}</div>
|
||||
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
{scene.effect === 'gyro' && scene.symbol && (
|
||||
<span style={{ position: 'absolute', width: 120, height: 120, borderRadius: '50%',
|
||||
border: `3px solid ${SCREEN.symbol.color}`, borderTopColor: 'transparent',
|
||||
animation: 'zacus-gyro-spin 3s linear infinite' }} />
|
||||
)}
|
||||
<span style={{ color: SCREEN.symbol.color, font: `700 40px ${SCREEN.symbol.font}`, letterSpacing: 3,
|
||||
animation: symbolAnimation(scene.effect) }}>{scene.symbol}</span>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', bottom: 10, left: 8, right: 8,
|
||||
color: SCREEN.subtitle.color, font: `18px ${SCREEN.subtitle.font}`,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{scene.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Réutiliser dans `ScreenEditorPanel`**
|
||||
|
||||
Dans `ScreenEditorPanel.tsx`, supprimer le `ScreenPreview`/`symbolAnimation` locaux et importer `import { DeviceScreenView } from './DeviceScreenView.js';` ; remplacer `<ScreenPreview scene={scene} />` par `<DeviceScreenView scene={scene} />`.
|
||||
|
||||
- [ ] **Step 3: Typecheck + build**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS.
|
||||
|
||||
### Task C2: selectionStore
|
||||
|
||||
**Files:**
|
||||
- Create: `src/stores/selectionStore.ts`
|
||||
|
||||
- [ ] **Step 1: Créer le store**
|
||||
|
||||
```ts
|
||||
// src/stores/selectionStore.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SelectionState {
|
||||
selectedStepId: string | null;
|
||||
selectStep: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useSelectionStore = create<SelectionState>((set) => ({
|
||||
selectedStepId: null,
|
||||
selectStep: (selectedStepId) => set({ selectedStepId }),
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: PASS.
|
||||
|
||||
### Task C3: Parser IR → steps + edges
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/irSteps.ts`
|
||||
|
||||
- [ ] **Step 1: Écrire le parseur**
|
||||
|
||||
```ts
|
||||
// src/lib/irSteps.ts
|
||||
// Parse le currentIr (string JSON) du runtimeStore en nœuds/arêtes pour le FlowGraph.
|
||||
import type { SceneDraft } from './screenSpec.js';
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
scene: SceneDraft; // title/subtitle/symbol/effect (defaults vides + effect 'pulse')
|
||||
}
|
||||
export interface FlowEdge { from: string; to: string; }
|
||||
export interface FlowGraphData { nodes: FlowNode[]; edges: FlowEdge[]; entry: string | null; }
|
||||
|
||||
interface RawStep {
|
||||
id?: string;
|
||||
scene?: { title?: string; subtitle?: string; symbol?: string; effect?: SceneDraft['effect'] };
|
||||
transitions?: { target_step_id?: string }[];
|
||||
}
|
||||
|
||||
export function parseIr(irJson: string | null): FlowGraphData {
|
||||
if (!irJson) return { nodes: [], edges: [], entry: null };
|
||||
let raw: { entry_step_id?: string; steps?: RawStep[] };
|
||||
try { raw = JSON.parse(irJson); } catch { return { nodes: [], edges: [], entry: null }; }
|
||||
const steps = raw.steps ?? [];
|
||||
const nodes: FlowNode[] = steps.filter((s) => s.id).map((s) => ({
|
||||
id: s.id as string,
|
||||
scene: {
|
||||
title: s.scene?.title ?? '',
|
||||
subtitle: s.scene?.subtitle ?? '',
|
||||
symbol: s.scene?.symbol ?? '',
|
||||
effect: s.scene?.effect ?? 'pulse',
|
||||
},
|
||||
}));
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const edges: FlowEdge[] = [];
|
||||
for (const s of steps) {
|
||||
if (!s.id) continue;
|
||||
for (const t of s.transitions ?? []) {
|
||||
if (t.target_step_id && ids.has(t.target_step_id)) edges.push({ from: s.id, to: t.target_step_id });
|
||||
}
|
||||
}
|
||||
return { nodes, edges, entry: raw.entry_step_id ?? nodes[0]?.id ?? null };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: PASS.
|
||||
|
||||
### Task C4: FlowGraph (liste ordonnée cliquable + arêtes)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/FlowGraph.tsx`
|
||||
|
||||
- [ ] **Step 1: Écrire le composant (V1 : colonne de nœuds + libellés de transition)**
|
||||
|
||||
```tsx
|
||||
// src/components/FlowGraph.tsx
|
||||
import { useMemo } from 'react';
|
||||
import { useRuntimeStore } from '../stores/runtimeStore.js';
|
||||
import { useSelectionStore } from '../stores/selectionStore.js';
|
||||
import { parseIr } from '../lib/irSteps.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
export function FlowGraph() {
|
||||
const ir = useRuntimeStore((s) => s.currentIr ?? s.pendingIr);
|
||||
const { selectedStepId, selectStep } = useSelectionStore();
|
||||
const { nodes, edges, entry } = useMemo(() => parseIr(ir), [ir]);
|
||||
|
||||
const outgoing = (id: string) => edges.filter((e) => e.from === id).map((e) => e.to);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return <div style={{ color: WB.label, padding: 8, fontFamily: WB.fontMono }}>Aucune étape — édite le scénario puis lance (Run).</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 8, overflowY: 'auto', fontFamily: WB.fontMono, fontSize: 12 }}>
|
||||
{nodes.map((n) => {
|
||||
const sel = n.id === selectedStepId;
|
||||
return (
|
||||
<div key={n.id}>
|
||||
<button type="button" onClick={() => selectStep(n.id)}
|
||||
style={{ width: '100%', textAlign: 'left', padding: '6px 8px', cursor: 'pointer',
|
||||
background: sel ? WB.orange : WB.panel, color: sel ? '#1a1a1d' : '#e8eef3',
|
||||
border: `1px solid ${sel ? WB.orange : WB.panelBorder}`, borderRadius: 5 }}>
|
||||
{n.id === entry ? '▶ ' : ''}{n.id}
|
||||
{n.scene.symbol ? <span style={{ color: sel ? '#1a1a1d' : WB.orange }}> ⟦{n.scene.symbol}⟧</span> : null}
|
||||
</button>
|
||||
{outgoing(n.id).map((to) => (
|
||||
<div key={to} style={{ color: WB.label, paddingLeft: 16 }}>↳ {to}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + build**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS.
|
||||
|
||||
### Task C5: DeviceScreen (écran de l'étape sélectionnée)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/DeviceScreen.tsx`
|
||||
|
||||
- [ ] **Step 1: Écrire le composant**
|
||||
|
||||
```tsx
|
||||
// src/components/DeviceScreen.tsx
|
||||
import { useMemo } from 'react';
|
||||
import { useRuntimeStore } from '../stores/runtimeStore.js';
|
||||
import { useSelectionStore } from '../stores/selectionStore.js';
|
||||
import { parseIr } from '../lib/irSteps.js';
|
||||
import { EMPTY_SCENE } from '../lib/screenSpec.js';
|
||||
import { DeviceScreenView } from './DeviceScreenView.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
export function DeviceScreen() {
|
||||
const ir = useRuntimeStore((s) => s.currentIr ?? s.pendingIr);
|
||||
const selectedStepId = useSelectionStore((s) => s.selectedStepId);
|
||||
const { nodes, entry } = useMemo(() => parseIr(ir), [ir]);
|
||||
const node = nodes.find((n) => n.id === selectedStepId) ?? nodes.find((n) => n.id === entry) ?? null;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 8, alignItems: 'center' }}>
|
||||
<span style={{ color: WB.label, fontFamily: WB.fontMono, fontSize: 11, alignSelf: 'flex-start' }}>
|
||||
ÉCRAN · {node ? node.id : '—'}
|
||||
</span>
|
||||
<DeviceScreenView scene={node ? node.scene : EMPTY_SCENE} scale={0.7} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + build**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS.
|
||||
|
||||
### Task C6: WorkbenchPane (split vertical) + branchement Layout
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/WorkbenchPane.tsx`
|
||||
- Modify: `src/components/Layout.tsx`
|
||||
|
||||
- [ ] **Step 1: Écrire le split vertical**
|
||||
|
||||
```tsx
|
||||
// src/components/WorkbenchPane.tsx
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { DeviceScreen } from './DeviceScreen.js';
|
||||
import { FlowGraph } from './FlowGraph.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
export function WorkbenchPane() {
|
||||
return (
|
||||
<div className="atelier-pane" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '4px 8px', color: WB.orange, fontFamily: WB.fontTitle, letterSpacing: 1,
|
||||
borderBottom: `1px solid ${WB.panelBorder}`, fontSize: 12 }}>BANC D'ESSAI</div>
|
||||
<PanelGroup direction="vertical" style={{ flex: 1 }}>
|
||||
<Panel defaultSize={45} minSize={20}><DeviceScreen /></Panel>
|
||||
<PanelResizeHandle className="atelier-resizer atelier-resizer--horizontal" />
|
||||
<Panel defaultSize={55} minSize={20}><FlowGraph /></Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Brancher dans Layout (remplacer le placeholder de A2)**
|
||||
|
||||
Dans `src/components/Layout.tsx` : `import { WorkbenchPane } from './WorkbenchPane.js';` et remplacer le `<div ...>Banc d'essai (à venir)</div>` par `<WorkbenchPane />`.
|
||||
|
||||
- [ ] **Step 3: Typecheck + build**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit && pnpm exec vite build`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit phase C**
|
||||
|
||||
```bash
|
||||
cd ../../.. && git add -A
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(atelier): Workbench pane — faithful device screen + scenario flow graph"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (couverture spec)
|
||||
|
||||
- Retrait 3D (spec §4 supprimés) → Phase A ✓
|
||||
- Thème Workbench palette/fonts/chrome/Blockly (spec §3) → Phase B ✓
|
||||
- DeviceScreen réutilisant P3a (spec §4 nouveaux) → C1/C5 ✓
|
||||
- FlowGraph depuis IR (spec §5) → C3/C4 ✓
|
||||
- selectionStore + wiring (spec §5) → C2, FlowGraph/DeviceScreen lisent `selectionStore` ✓
|
||||
- Split vertical (spec §2, décision) → C6 ✓
|
||||
- Tests = tsc+build verts par tâche (spec §7) ✓
|
||||
- Hors périmètre (Blockly conservé, pas de playtest, pas dashboard) respecté ✓
|
||||
|
||||
## Notes d'exécution
|
||||
|
||||
- Le `FlowGraph` V1 est une **liste ordonnée de nœuds + arêtes sortantes** (le plus sûr) ; un layout graphe spatial (positions/SVG) est une amélioration ultérieure hors de ce plan.
|
||||
- Vérif visuelle finale recommandée via `pnpm --filter @zacus/atelier dev` (hors CI) après C6.
|
||||
- Déploiement prod (`ops/deploy-frontend.sh atelier`) **hors de ce plan** — à faire après revue visuelle.
|
||||
@@ -0,0 +1,948 @@
|
||||
# PLIP — Téléphone conversationnel France Télécom — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Faire du PLIP un téléphone France Télécom interactif : décroché → tonalité → numérotation (cadran/DTMF) → annuaire → PNJ vocal conversationnel (Zacus & services d'époque).
|
||||
|
||||
**Architecture:** Le firmware PLIP possède la machine à états téléphonique ; la gateway expose `POST /v1/voice/turn` (stateless/tour) qui enchaîne whisper(STT) → ailiance(chat) → ailiance(TTS) → resample 16k. Le PLIP ne fait ni TLS, ni prompt, ni resample.
|
||||
|
||||
**Tech Stack:** ESP-IDF (esp32, plip_voice : net.c httpd, audio.c I2S/ES8388, phone.c hook), SLIC classe AG1171 (SHK = hook+impulsions). Gateway FastAPI/httpx (`tools/zacus-gateway/main.py`), pytest. TTS+LLM via `gateway.ailiance.fr` (OpenAI-compatible).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-14-plip-telephone-conversation-design.md`
|
||||
|
||||
**Référence (numéros de ligne au moment de la rédaction) :** patterns dans `ESP32_ZACUS/plip_voice/main/{net.c,audio.c,phone.c,cmd_exec.c,hook_client.c,Kconfig.projbuild,board_config.h,CMakeLists.txt}` et `tools/zacus-gateway/main.py` (`_synthesise_wav` ~l.701, `_wav_to_16k_mono` ~l.734, route `/v1/voice/say` ~l.1358).
|
||||
|
||||
**Convention build/flash firmware :** `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`. Série : `/dev/cu.usbserial-0001` (un lecteur à la fois). PLIP IP 192.168.0.138.
|
||||
|
||||
**Convention commits firmware (submodule) :** `git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "..."`. JAMAIS `--no-verify`. Commits gateway : depuis la racine repo.
|
||||
|
||||
---
|
||||
|
||||
## STAGE 1 — Tonalités + dialer + machine à états (firmware, zéro IA)
|
||||
|
||||
Livrable : décroché → tonalité 440 Hz ; composer un numéro (debug HTTP + impulsions SHK) → ringback si numéro connu / occupé sinon ; raccroché = mute. Aucune conversation encore.
|
||||
|
||||
### Task 1.1 : Module `tones` (tonalités France Télécom)
|
||||
|
||||
**Files:**
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/tones.h`
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/tones.c`
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt` (ajouter `tones.c` aux SRCS)
|
||||
|
||||
- [ ] **Step 1 : Écrire `tones.h`**
|
||||
|
||||
```c
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
// Tonalités France Télécom — générées via le DAC/I2S, non bloquant (tâche dédiée).
|
||||
// Toutes forcent le PA on (audio_pa_set(true)) le temps de jouer.
|
||||
void tones_dialtone_start(void); // 440 Hz continu (invitation à numéroter)
|
||||
void tones_busy_start(void); // 440 Hz 0,5 s on / 0,5 s off (occupé / non attribué)
|
||||
void tones_ringback_start(void); // 440 Hz 1,5 s on / 3,5 s off (retour d'appel)
|
||||
void tones_stop(void); // arrête toute tonalité en cours
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire `tones.c`**
|
||||
|
||||
Génère un sinus 440 Hz en PCM 16 kHz mono et l'écrit en boucle sur l'I2S TX (`audio_spk_handle()`), avec un masque on/off selon la tonalité. Une seule tâche FreeRTOS `s_tone_task`, un `volatile tone_mode_t s_mode` (NONE/DIAL/BUSY/RINGBACK). `tones_stop` met NONE + `audio_pa_set` géré par l'appelant (la machine à états). Modèle : la génération de tone dans `audio.c::audio_play_tone` (sinus + `i2s_channel_write` sur `audio_spk_handle()`).
|
||||
|
||||
```c
|
||||
#include "tones.h"
|
||||
#include "audio.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/i2s_std.h"
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef enum { TONE_NONE, TONE_DIAL, TONE_BUSY, TONE_RINGBACK } tone_mode_t;
|
||||
static volatile tone_mode_t s_mode = TONE_NONE;
|
||||
static TaskHandle_t s_task;
|
||||
#define SR 16000
|
||||
#define FRAME 320 // 20 ms
|
||||
|
||||
static void fill_440(int16_t *buf, int n, int *phase) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
buf[i] = (int16_t)(8000.0f * sinf(2.0f * (float)M_PI * 440.0f * (*phase) / SR));
|
||||
(*phase)++;
|
||||
}
|
||||
}
|
||||
|
||||
static void tone_task(void *arg) {
|
||||
int16_t buf[FRAME];
|
||||
int phase = 0;
|
||||
int64_t t = 0; // ms écoulées dans le cycle on/off
|
||||
for (;;) {
|
||||
tone_mode_t m = s_mode;
|
||||
if (m == TONE_NONE) { vTaskDelay(pdMS_TO_TICKS(20)); t = 0; continue; }
|
||||
bool on = true;
|
||||
if (m == TONE_BUSY) on = (t % 1000) < 500;
|
||||
else if (m == TONE_RINGBACK) on = (t % 5000) < 1500;
|
||||
if (on) fill_440(buf, FRAME, &phase);
|
||||
else memset(buf, 0, sizeof(buf));
|
||||
size_t w;
|
||||
i2s_channel_write(audio_spk_handle(), buf, sizeof(buf), &w, portMAX_DELAY);
|
||||
t += 20;
|
||||
}
|
||||
}
|
||||
|
||||
static void ensure_task(void) {
|
||||
if (!s_task) xTaskCreate(tone_task, "tones", 3072, NULL, 5, &s_task);
|
||||
}
|
||||
void tones_dialtone_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_DIAL; }
|
||||
void tones_busy_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_BUSY; }
|
||||
void tones_ringback_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_RINGBACK; }
|
||||
void tones_stop(void){ s_mode = TONE_NONE; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter `tones.c` au CMakeLists**
|
||||
|
||||
Dans `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`, ajouter `"tones.c"` dans `SRCS` (à côté de `"audio.c"`).
|
||||
|
||||
- [ ] **Step 4 : Build**
|
||||
|
||||
Run: `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build`
|
||||
Expected: build vert, `tones.c` compilé.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -am "feat(plip): tones module — France Télécom dial/busy/ringback"
|
||||
```
|
||||
|
||||
### Task 1.2 : Module `dialer` (numéro composé : debug + impulsions SHK)
|
||||
|
||||
**Files:**
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/dialer.h`
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/dialer.c`
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/net.c` (handler `/debug/dial`)
|
||||
|
||||
- [ ] **Step 1 : Écrire `dialer.h`**
|
||||
|
||||
```c
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
// Le dialer accumule des chiffres (source : impulsions SHK, DTMF, ou /debug/dial)
|
||||
// et expose le numéro courant. La machine à états (conversation.c) lit
|
||||
// dialer_current()/dialer_complete() et appelle dialer_reset() à la fin d'un appel.
|
||||
void dialer_init(void);
|
||||
void dialer_push_digit(int d); // 0..9 ; appelé par les sources (debug, pulse, dtmf)
|
||||
void dialer_reset(void); // vide le numéro (raccroché / fin d'appel)
|
||||
const char *dialer_current(void); // ex. "17" (string null-terminée, max 8)
|
||||
bool dialer_idle(void); // true si aucun chiffre depuis le reset
|
||||
int dialer_ms_since_last(void); // ms depuis le dernier chiffre (pour timeout inter-chiffre)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire `dialer.c`** (accumulateur + horodatage)
|
||||
|
||||
```c
|
||||
#include "dialer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "esp_timer.h"
|
||||
#include <string.h>
|
||||
|
||||
static char s_num[9];
|
||||
static int s_len;
|
||||
static int64_t s_last_us;
|
||||
|
||||
void dialer_init(void){ dialer_reset(); }
|
||||
void dialer_reset(void){ s_num[0]=0; s_len=0; s_last_us=0; }
|
||||
void dialer_push_digit(int d){
|
||||
if (d<0||d>9||s_len>=8) return;
|
||||
s_num[s_len++] = (char)('0'+d); s_num[s_len]=0;
|
||||
s_last_us = esp_timer_get_time();
|
||||
}
|
||||
const char *dialer_current(void){ return s_num; }
|
||||
bool dialer_idle(void){ return s_len==0; }
|
||||
int dialer_ms_since_last(void){
|
||||
if (!s_last_us) return 0;
|
||||
return (int)((esp_timer_get_time()-s_last_us)/1000);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Handler `/debug/dial?number=NNNN` dans `net.c`**
|
||||
|
||||
Calque le pattern `handle_status`/`handle_cmd_post` (net.c:127, 407). Parse le query `number`, appelle `dialer_push_digit` pour chaque chiffre. Enregistre `uri_debug_dial` (HTTP_GET) et **incrémente `cfg.max_uri_handlers`** (actuellement 12 → 14, on ajoutera aussi `/debug/onhook` et plus tard rien d'autre côté net). Include `dialer.h`.
|
||||
|
||||
```c
|
||||
static esp_err_t handle_debug_dial(httpd_req_t *req) {
|
||||
char q[64], val[16];
|
||||
if (httpd_req_get_url_query_str(req, q, sizeof(q)) == ESP_OK &&
|
||||
httpd_query_key_value(q, "number", val, sizeof(val)) == ESP_OK) {
|
||||
for (const char *p = val; *p; p++) if (*p>='0'&&*p<='9') dialer_push_digit(*p-'0');
|
||||
return send_json(req, "200 OK", "{\"ok\":true}");
|
||||
}
|
||||
return send_json(req, "400 Bad Request", "{\"error\":\"number?\"}");
|
||||
}
|
||||
static const httpd_uri_t uri_debug_dial = {
|
||||
.uri="/debug/dial", .method=HTTP_GET, .handler=handle_debug_dial, .user_ctx=NULL };
|
||||
// dans la fonction d'enregistrement : httpd_register_uri_handler(s_httpd, &uri_debug_dial);
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter `dialer.c` au CMakeLists + build**
|
||||
|
||||
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build`
|
||||
Expected: vert.
|
||||
|
||||
- [ ] **Step 5 : Flash + test debug dial**
|
||||
|
||||
```bash
|
||||
idf.py -p /dev/cu.usbserial-0001 flash
|
||||
curl -s "http://192.168.0.138/debug/dial?number=17" # -> {"ok":true}
|
||||
```
|
||||
Expected: `{"ok":true}`, série logue les chiffres (ajouter un `ESP_LOGI` dans push_digit pour vérifier).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git -C ESP32_ZACUS ... commit -am "feat(plip): dialer module + /debug/dial (digit accumulator)"
|
||||
```
|
||||
|
||||
### Task 1.3 : Impulsions cadran sur SHK (comptage)
|
||||
|
||||
**Files:**
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (compter les impulsions sur le GPIO hook = SHK)
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/Kconfig.projbuild` (option pulse)
|
||||
|
||||
- [ ] **Step 1 : Kconfig pulse**
|
||||
|
||||
Dans `Kconfig.projbuild`, ajouter sous le menu PLIP :
|
||||
```
|
||||
config PLIP_DIAL_PULSE
|
||||
bool "Enable rotary pulse dialing on the hook/SHK GPIO"
|
||||
default y
|
||||
config PLIP_DIAL_PULSE_MAX_GAP_MS
|
||||
int "Inter-pulse max gap (ms) — above this = digit complete"
|
||||
depends on PLIP_DIAL_PULSE
|
||||
default 200
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Compter les impulsions dans `phone.c`**
|
||||
|
||||
Le cadran génère des ouvertures/fermetures rapides de la boucle pendant la composition (le SHK pulse), distinctes du décroché/raccroché (état stable). Dans la task hook (phone.c:86-111) : si `PLIP_DIAL_PULSE` et qu'on est **off-hook**, compter les transitions rapides ; quand le gap depuis la dernière impulsion dépasse `CONFIG_PLIP_DIAL_PULSE_MAX_GAP_MS`, le nombre d'impulsions = le chiffre (10 impulsions = 0) → `dialer_push_digit`. Garder la détection on/off-hook stable (raccroché = boucle ouverte >~500 ms). Include `dialer.h`.
|
||||
|
||||
```c
|
||||
// pseudo-intégration dans la task hook, après lecture du niveau débouncé :
|
||||
#if CONFIG_PLIP_DIAL_PULSE
|
||||
static int s_pulse_count = 0;
|
||||
static int64_t s_last_pulse_us = 0;
|
||||
// sur front descendant bref (impulsion) pendant off-hook : s_pulse_count++; s_last_pulse_us=now;
|
||||
// périodiquement : si s_pulse_count>0 && (now - s_last_pulse_us) > gap_ms :
|
||||
// int digit = (s_pulse_count==10)?0:s_pulse_count; dialer_push_digit(digit); s_pulse_count=0;
|
||||
#endif
|
||||
```
|
||||
|
||||
(Note implémenteur : le débounce hook actuel 30 ms peut masquer les impulsions cadran ~ 60-100 ms ; ajuster la logique pour distinguer impulsion brève vs raccroché stable. Tester au banc avec un vrai cadran ; sans cadran, `/debug/dial` couvre la suite.)
|
||||
|
||||
- [ ] **Step 3 : Build + flash**
|
||||
|
||||
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
|
||||
Expected: vert ; au banc avec cadran (si dispo) composer "1" → série logue digit=1. Sans cadran : skip, `/debug/dial` reste la voie de test.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git -C ESP32_ZACUS ... commit -am "feat(plip): rotary pulse dialing on SHK GPIO"
|
||||
```
|
||||
|
||||
### Task 1.4 : Machine à états `conversation` jusqu'à RINGBACK/BUSY
|
||||
|
||||
**Files:**
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/conversation.h`
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/conversation.c`
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (notifier conversation des transitions hook)
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/main.c` (init conversation)
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`
|
||||
|
||||
- [ ] **Step 1 : Écrire `conversation.h`**
|
||||
|
||||
```c
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
void conversation_init(void);
|
||||
void conversation_on_hook_change(bool offhook); // appelé par phone.c
|
||||
// Stage 1 : IDLE → DIAL_TONE → DIALING → ROUTING → RINGBACK|BUSY → (Stage 2: CONNECT…)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire `conversation.c`** (tâche d'état, Stage 1)
|
||||
|
||||
Tâche FreeRTOS qui poll l'état. Au décroché : `tones_dialtone_start()`. Dès qu'un chiffre apparaît (`!dialer_idle()`) : `tones_stop()`, passe en DIALING. Quand `dialer_ms_since_last() > 3000` (timeout inter-chiffre) OU le numéro matche une entrée connue : ROUTING. Routing Stage 1 = table locale des numéros valides (`"12","3615","15","17","18"` + Zacus) ; connu → `tones_ringback_start()` (2 s puis stop, Stage 2 enchaînera CONNECT) ; inconnu → `tones_busy_start()` (5 s) → IDLE. Au raccroché : `tones_stop()`, `audio_stop()`, `audio_pa_set(false)`, `dialer_reset()`, IDLE.
|
||||
|
||||
```c
|
||||
#include "conversation.h"
|
||||
#include "tones.h"
|
||||
#include "dialer.h"
|
||||
#include "audio.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "conv";
|
||||
typedef enum { ST_IDLE, ST_DIALTONE, ST_DIALING, ST_RINGBACK, ST_BUSY } st_t;
|
||||
static volatile bool s_offhook;
|
||||
static volatile st_t s_st = ST_IDLE;
|
||||
|
||||
static const char *KNOWN[] = { "12","3615","15","17","18", NULL };
|
||||
static bool number_known(const char *n){
|
||||
for (int i=0; KNOWN[i]; i++) if (!strcmp(n, KNOWN[i])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void conv_task(void *arg){
|
||||
for (;;){
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
if (!s_offhook){
|
||||
if (s_st != ST_IDLE){ tones_stop(); audio_stop(); audio_pa_set(false); dialer_reset(); s_st=ST_IDLE; }
|
||||
continue;
|
||||
}
|
||||
switch (s_st){
|
||||
case ST_IDLE: tones_dialtone_start(); s_st=ST_DIALTONE; break;
|
||||
case ST_DIALTONE:
|
||||
if (!dialer_idle()){ tones_stop(); s_st=ST_DIALING; }
|
||||
break;
|
||||
case ST_DIALING: {
|
||||
const char *n = dialer_current();
|
||||
if (number_known(n)){
|
||||
ESP_LOGI(TAG, "route %s -> known", n);
|
||||
tones_ringback_start(); s_st=ST_RINGBACK; // Stage 2: enchaîner CONNECT
|
||||
} else if (dialer_ms_since_last() > 3000){
|
||||
ESP_LOGI(TAG, "route %s -> unknown", n);
|
||||
tones_busy_start(); s_st=ST_BUSY;
|
||||
}
|
||||
break; }
|
||||
case ST_RINGBACK: /* Stage 2 remplira CONNECT/GREET ici */ break;
|
||||
case ST_BUSY: break; // reste occupé jusqu'au raccroché
|
||||
}
|
||||
}
|
||||
}
|
||||
void conversation_init(void){ xTaskCreate(conv_task,"conv",4096,NULL,5,NULL); }
|
||||
void conversation_on_hook_change(bool offhook){ s_offhook = offhook; if(!offhook) s_st=ST_IDLE; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Brancher phone.c → conversation**
|
||||
|
||||
Dans la task hook de `phone.c` (là où `hook_client_report` est appelé), ajouter `conversation_on_hook_change(offhook)` (offhook = `phone_is_offhook()`). Include `conversation.h`.
|
||||
|
||||
- [ ] **Step 4 : Init dans main.c**
|
||||
|
||||
Dans `ESP32_ZACUS/plip_voice/main/main.c`, après `audio_init()` et l'init réseau, ajouter `dialer_init(); conversation_init();`. Include les headers.
|
||||
|
||||
- [ ] **Step 5 : Ajouter conversation.c au CMakeLists + build + flash**
|
||||
|
||||
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
|
||||
|
||||
- [ ] **Step 6 : Test au banc (séquence complète Stage 1)**
|
||||
|
||||
Décroché (ou simuler off-hook) → tonalité au HP. Puis :
|
||||
```bash
|
||||
curl -s "http://192.168.0.138/debug/dial?number=17" # numéro connu
|
||||
# série attendue : "route 17 -> known" + ringback au HP
|
||||
curl -s "http://192.168.0.138/debug/dial?number=99" # après reset/raccroché
|
||||
# série attendue : "route 99 -> unknown" + busy au HP (après timeout 3s)
|
||||
```
|
||||
Raccroché → silence (PA off). Expected : tonalité → ringback (connu) / occupé (inconnu) → silence au raccroché.
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git -C ESP32_ZACUS ... commit -am "feat(plip): conversation state machine — dialtone/dialing/routing/ringback/busy (stage 1)"
|
||||
```
|
||||
|
||||
### Task 1.5 : Lander Stage 1 (PR submodule + bump pointeur)
|
||||
|
||||
- [ ] **Step 1 : PR submodule ESP32_ZACUS** (branche `feat/plip-phone-stage1`), merge via API Gitea (voir conventions de session : push avec token, PR, merge, ff-only main).
|
||||
- [ ] **Step 2 : Bump pointeur** dans le superprojet (`git add ESP32_ZACUS`, commit `chore: bump ESP32_ZACUS — PLIP phone stage 1`, PR, merge).
|
||||
|
||||
---
|
||||
|
||||
## STAGE 2 — Gateway `/v1/voice/turn` (voie greeting) + annuaire (téléphone jouable sens unique)
|
||||
|
||||
Livrable : composer un numéro → **entendre le PNJ débiter son accueil** (TTS ailiance). Sans micro ni whisper.
|
||||
|
||||
### Task 2.1 : Annuaire `phone_directory.yaml` + chargement gateway
|
||||
|
||||
**Files:**
|
||||
- Create: `game/scenarios/phone_directory.yaml`
|
||||
- Modify: `tools/zacus-gateway/main.py` (loader + Settings)
|
||||
- Test: `tests/gateway/test_phone_directory.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire l'annuaire**
|
||||
|
||||
```yaml
|
||||
# game/scenarios/phone_directory.yaml — numéro → persona vocale
|
||||
# persona = prompt système (personnage) ; voice = voix ailiance ; greeting = 1re réplique forcée (optionnel)
|
||||
numbers:
|
||||
"12":
|
||||
label: "Renseignements"
|
||||
voice: "nova"
|
||||
persona: >
|
||||
Tu es l'opératrice des renseignements téléphoniques de France Télécom, années 80.
|
||||
Tu es polie, un peu pressée. Tu orientes l'appelant vers les bons services sans
|
||||
jamais donner directement la solution d'une énigme.
|
||||
greeting: "Renseignements, bonjour. Quel numéro demandez-vous ?"
|
||||
"15":
|
||||
label: "SAMU"
|
||||
voice: "nova"
|
||||
persona: >
|
||||
Tu es un régulateur du SAMU. Tu réponds avec calme et autorité. Dans cet escape game
|
||||
tu peux donner un indice médical déguisé, mais jamais la réponse directe.
|
||||
greeting: "SAMU, j'écoute, quelle est votre urgence ?"
|
||||
"17":
|
||||
label: "Police secours"
|
||||
voice: "alloy"
|
||||
persona: >
|
||||
Tu es un agent de Police secours, factuel et sérieux. Indices déguisés, jamais la solution.
|
||||
greeting: "Police secours, je vous écoute."
|
||||
"18":
|
||||
label: "Pompiers"
|
||||
voice: "alloy"
|
||||
persona: >
|
||||
Tu es un sapeur-pompier, direct et rassurant. Indices déguisés, jamais la solution.
|
||||
greeting: "Sapeurs-pompiers, bonjour, quel est le problème ?"
|
||||
"3615":
|
||||
label: "Minitel"
|
||||
voice: "nova"
|
||||
minitel: true
|
||||
persona: >
|
||||
Tu es un service Minitel automatisé (3615). Tu parles de façon datée, robotique,
|
||||
télématique. Réponses courtes façon serveur télétel.
|
||||
greeting: "Trois mille six cent quinze. Service en ligne. Tapez votre requête."
|
||||
"0142738200":
|
||||
label: "Professeur Zacus"
|
||||
voice: "nova"
|
||||
persona: >
|
||||
Tu es le Professeur Zacus, savant excentrique et théâtral. Tu guides les joueurs
|
||||
par des indices déguisés et des énigmes, jamais la solution directe. Tu connais le
|
||||
scénario en cours mais restes mystérieux.
|
||||
greeting: "Ah... vous avez réussi à me joindre. Ici le Professeur Zacus."
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le test (échoue d'abord)**
|
||||
|
||||
`tests/gateway/test_phone_directory.py` :
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
|
||||
import main
|
||||
|
||||
def test_directory_loads_known_numbers():
|
||||
d = main.load_phone_directory()
|
||||
assert "17" in d and d["17"]["label"] == "Police secours"
|
||||
assert "3615" in d and d["3615"].get("minitel") is True
|
||||
assert "voice" in d["12"] and "persona" in d["12"]
|
||||
|
||||
def test_directory_unknown_number_absent():
|
||||
d = main.load_phone_directory()
|
||||
assert "99" not in d
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer le test → échoue**
|
||||
|
||||
Run: `cd /Users/electron/code/zacus && python3 -m pytest tests/gateway/test_phone_directory.py -v`
|
||||
Expected: FAIL (`load_phone_directory` n'existe pas).
|
||||
|
||||
- [ ] **Step 4 : Implémenter le loader**
|
||||
|
||||
Dans `tools/zacus-gateway/main.py`, près du `_load_boards_registry` (~l.32) :
|
||||
```python
|
||||
def load_phone_directory() -> dict:
|
||||
import yaml
|
||||
p = SCENARIOS_DIR / "phone_directory.yaml" # SCENARIOS_DIR = REPO_ROOT/game/scenarios
|
||||
if not p.is_file():
|
||||
return {}
|
||||
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
return data.get("numbers", {})
|
||||
|
||||
PHONE_DIRECTORY = load_phone_directory()
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer le test → passe**
|
||||
|
||||
Run: `python3 -m pytest tests/gateway/test_phone_directory.py -v`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add game/scenarios/phone_directory.yaml tools/zacus-gateway/main.py tests/gateway/test_phone_directory.py
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): phone directory loader (numéro → persona/voix)"
|
||||
```
|
||||
|
||||
### Task 2.2 : Sessions en mémoire (historique conversation)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/zacus-gateway/main.py`
|
||||
- Test: `tests/gateway/test_voice_session.py`
|
||||
|
||||
- [ ] **Step 1 : Test (échoue)**
|
||||
|
||||
```python
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
|
||||
import main
|
||||
|
||||
def test_session_appends_and_caps():
|
||||
s = main.VoiceSessions(ttl_s=600, max_turns=8)
|
||||
s.append("sid1", "user", "bonjour")
|
||||
s.append("sid1", "assistant", "ici Zacus")
|
||||
msgs = s.history("sid1")
|
||||
assert msgs[-2:] == [{"role":"user","content":"bonjour"},
|
||||
{"role":"assistant","content":"ici Zacus"}]
|
||||
for i in range(40): s.append("sid1", "user", f"m{i}")
|
||||
assert len(s.history("sid1")) <= 16 # 8 tours * 2
|
||||
|
||||
def test_session_end_clears():
|
||||
s = main.VoiceSessions()
|
||||
s.append("sid2","user","x"); s.end("sid2")
|
||||
assert s.history("sid2") == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → FAIL.
|
||||
|
||||
- [ ] **Step 3 : Implémenter `VoiceSessions`** dans `main.py` :
|
||||
```python
|
||||
class VoiceSessions:
|
||||
def __init__(self, ttl_s: int = 600, max_turns: int = 8):
|
||||
self._d: dict[str, dict] = {}
|
||||
self._ttl = ttl_s
|
||||
self._max = max_turns * 2
|
||||
def append(self, sid: str, role: str, content: str) -> None:
|
||||
import time
|
||||
e = self._d.setdefault(sid, {"msgs": [], "ts": 0.0})
|
||||
e["msgs"].append({"role": role, "content": content})
|
||||
e["msgs"] = e["msgs"][-self._max:]
|
||||
e["ts"] = time.time()
|
||||
def history(self, sid: str) -> list[dict]:
|
||||
import time
|
||||
e = self._d.get(sid)
|
||||
if not e: return []
|
||||
if time.time() - e["ts"] > self._ttl:
|
||||
self._d.pop(sid, None); return []
|
||||
return list(e["msgs"])
|
||||
def end(self, sid: str) -> None:
|
||||
self._d.pop(sid, None)
|
||||
|
||||
VOICE_SESSIONS = VoiceSessions()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → PASS.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
```bash
|
||||
git add tools/zacus-gateway/main.py tests/gateway/test_voice_session.py
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): in-memory voice sessions (history + TTL)"
|
||||
```
|
||||
|
||||
### Task 2.3 : LLM chat helper (ailiance)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/zacus-gateway/main.py` (Settings + `_chat_reply`)
|
||||
- Test: `tests/gateway/test_chat_reply.py`
|
||||
|
||||
- [ ] **Step 1 : Test (mock httpx)**
|
||||
```python
|
||||
import sys, asyncio
|
||||
from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
|
||||
import main
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
def json(self): return {"choices":[{"message":{"content":"Ici Zacus."}}]}
|
||||
class FakeHTTP:
|
||||
async def post(self, *a, **k): return FakeResp()
|
||||
|
||||
def test_chat_reply_returns_content():
|
||||
out = asyncio.run(main._chat_reply(FakeHTTP(), "persona", [{"role":"user","content":"allo"}]))
|
||||
assert out == "Ici Zacus."
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → FAIL.
|
||||
|
||||
- [ ] **Step 3 : Implémenter** dans `main.py` (Settings : `ailiance_chat_model: str = "gpt-4o-mini"`) :
|
||||
```python
|
||||
async def _chat_reply(http, persona: str, history: list[dict]) -> str:
|
||||
messages = [{"role": "system", "content": persona}] + history
|
||||
resp = await http.post(
|
||||
f"{settings.ailiance_tts_url.rstrip('/')}/v1/chat/completions",
|
||||
json={"model": settings.ailiance_chat_model, "messages": messages, "max_tokens": 200},
|
||||
timeout=60.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"chat HTTP {resp.status_code}")
|
||||
return resp.json()["choices"][0]["message"]["content"].strip()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → PASS.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
```bash
|
||||
git add tools/zacus-gateway/main.py tests/gateway/test_chat_reply.py
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): ailiance chat helper for NPC replies"
|
||||
```
|
||||
|
||||
### Task 2.4 : Route `/v1/voice/turn` (voie greeting) + `/v1/voice/end`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/zacus-gateway/main.py`
|
||||
- Test: `tests/gateway/test_voice_turn.py`
|
||||
|
||||
- [ ] **Step 1 : Test voie greeting (mock chat + TTS)**
|
||||
```python
|
||||
import sys, asyncio
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
|
||||
import main
|
||||
|
||||
def test_turn_greeting_returns_wav(monkeypatch):
|
||||
async def fake_chat(http, persona, history): return "Police secours, j'écoute."
|
||||
async def fake_synth(http, text, voice):
|
||||
# WAV 16k mono minuscule valide
|
||||
import struct
|
||||
pcm = b"\x00\x00" * 1600
|
||||
hdr = b"RIFF" + struct.pack("<I", 36+len(pcm)) + b"WAVEfmt " + struct.pack("<IHHIIHH",16,1,1,16000,32000,2,16) + b"data" + struct.pack("<I", len(pcm))
|
||||
return hdr + pcm, 0.1, False
|
||||
monkeypatch.setattr(main, "_chat_reply", fake_chat)
|
||||
monkeypatch.setattr(main, "_voice_tts_16k", fake_synth)
|
||||
c = TestClient(main.app)
|
||||
r = c.post("/v1/voice/turn",
|
||||
headers={"Authorization": f"Bearer {main.settings.token}"},
|
||||
json={"session_id":"s1","number":"17","kind":"greeting"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "audio/wav"
|
||||
assert r.headers.get("x-zacus-said") # texte de réponse en header
|
||||
assert r.content[:4] == b"RIFF"
|
||||
|
||||
def test_turn_unknown_number_404():
|
||||
c = TestClient(main.app)
|
||||
r = c.post("/v1/voice/turn",
|
||||
headers={"Authorization": f"Bearer {main.settings.token}"},
|
||||
json={"session_id":"s1","number":"99","kind":"greeting"})
|
||||
assert r.status_code == 404
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → FAIL.
|
||||
|
||||
- [ ] **Step 3 : Implémenter le helper TTS 16k + la route**
|
||||
|
||||
Helper (réutilise `_synthesise_wav` ailiance + `_wav_to_16k_mono`) :
|
||||
```python
|
||||
async def _voice_tts_16k(http, text: str, voice: str | None) -> tuple[bytes, float, bool]:
|
||||
from types import SimpleNamespace
|
||||
raw = await _synthesise_wav(http, SimpleNamespace(backend="ailiance", text=text, voice=voice))
|
||||
return _wav_to_16k_mono(raw, max_seconds=7.5)
|
||||
```
|
||||
Route :
|
||||
```python
|
||||
from fastapi import Response
|
||||
|
||||
class VoiceTurnRequest(BaseModel):
|
||||
session_id: str
|
||||
number: str
|
||||
kind: str = "reply" # "greeting" | "reply"
|
||||
|
||||
@app.post("/v1/voice/turn")
|
||||
async def voice_turn(body: VoiceTurnRequest, request: Request, _: None = Depends(require_token)):
|
||||
entry = PHONE_DIRECTORY.get(body.number)
|
||||
if not entry:
|
||||
raise HTTPException(404, f"numéro {body.number} non attribué")
|
||||
persona = entry.get("persona", "")
|
||||
voice = entry.get("voice")
|
||||
http = request.app.state.http
|
||||
heard = ""
|
||||
if body.kind == "greeting":
|
||||
said = entry.get("greeting") or await _chat_reply(http, persona, [])
|
||||
else:
|
||||
# Stage 3 remplira la transcription depuis le corps audio ; ici fallback vide
|
||||
heard = ""
|
||||
VOICE_SESSIONS.append(body.session_id, "user", heard or "(silence)")
|
||||
said = await _chat_reply(http, persona, VOICE_SESSIONS.history(body.session_id))
|
||||
VOICE_SESSIONS.append(body.session_id, "assistant", said)
|
||||
wav16, _dur, _trunc = await _voice_tts_16k(http, said, voice)
|
||||
return Response(content=wav16, media_type="audio/wav",
|
||||
headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]})
|
||||
|
||||
class VoiceEndRequest(BaseModel):
|
||||
session_id: str
|
||||
|
||||
@app.post("/v1/voice/end")
|
||||
async def voice_end(body: VoiceEndRequest, _: None = Depends(require_token)) -> dict:
|
||||
VOICE_SESSIONS.end(body.session_id)
|
||||
return {"ok": True}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5 : Test live contre ailiance (manuel, hors CI)**
|
||||
|
||||
Monter le gateway local (venv + `ZACUS_HUB_TOKEN`), puis :
|
||||
```bash
|
||||
curl -s -XPOST http://127.0.0.1:8400/v1/voice/turn \
|
||||
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
|
||||
-d '{"session_id":"t1","number":"17","kind":"greeting"}' -o /tmp/greet.wav -D -
|
||||
file /tmp/greet.wav # RIFF WAVE 16000 Hz
|
||||
```
|
||||
Expected : 200, header `X-Zacus-Said`, WAV 16k jouable.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
```bash
|
||||
git add tools/zacus-gateway/main.py tests/gateway/test_voice_turn.py
|
||||
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): POST /v1/voice/turn (greeting path) + /v1/voice/end"
|
||||
```
|
||||
|
||||
### Task 2.5 : Firmware — CONNECT/GREET appelle `/v1/voice/turn` et joue l'accueil
|
||||
|
||||
**Files:**
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECT/GREET)
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/turn_client.h` / `turn_client.c` (POST /v1/voice/turn, reçoit WAV → SPIFFS → play)
|
||||
- Modify: `Kconfig.projbuild` (URL gateway + token)
|
||||
|
||||
- [ ] **Step 1 : Kconfig gateway**
|
||||
```
|
||||
config PLIP_GATEWAY_URL
|
||||
string "Zacus gateway base URL"
|
||||
default "http://192.168.0.188:8400" # ajuster à l'hôte gateway joignable du PLIP
|
||||
config PLIP_GATEWAY_TOKEN
|
||||
string "Zacus gateway bearer token"
|
||||
default ""
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : `turn_client.c`** — POST greeting, écrit le WAV reçu dans `/spiffs/turn.wav`, retourne le chemin.
|
||||
|
||||
Calque `hook_client.c` (esp_http_client POST) mais : body JSON `{"session_id","number","kind":"greeting"}`, header `Authorization: Bearer <token>`, et **lecture de la réponse binaire** (WAV) en streaming → `fopen("/spiffs/turn.wav","wb")` + `esp_http_client_read`. Signature :
|
||||
```c
|
||||
// turn_client.h
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
// POST /v1/voice/turn (greeting). Écrit le WAV dans out_path (/spiffs/...). true si OK.
|
||||
bool turn_client_greeting(const char *session_id, const char *number, const char *out_path);
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : État CONNECT/GREET dans conversation.c**
|
||||
|
||||
Après RINGBACK (~2 s), au lieu de rester : générer un `session_id` (ex. `snprintf("%lld", esp_timer_get_time())`), appeler `turn_client_greeting(sid, dialer_current(), "/spiffs/turn.wav")`, puis `tones_stop(); audio_play_async("/spiffs/turn.wav");` → état CONNECTED. (Stage 3 ajoutera la boucle LISTEN.)
|
||||
|
||||
```c
|
||||
case ST_RINGBACK:
|
||||
if (ringback_elapsed_ms() > 2000){
|
||||
tones_stop();
|
||||
snprintf(s_sid, sizeof(s_sid), "%lld", esp_timer_get_time());
|
||||
if (turn_client_greeting(s_sid, dialer_current(), "/spiffs/turn.wav"))
|
||||
audio_play_async("/spiffs/turn.wav");
|
||||
s_st = ST_CONNECTED;
|
||||
}
|
||||
break;
|
||||
case ST_CONNECTED: /* Stage 3: LISTEN → turn(reply) → play, en boucle */ break;
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Build + flash + test bout-en-bout (sens unique)**
|
||||
|
||||
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
|
||||
Test : gateway prod/local joignable depuis le PLIP. Décroché → tonalité → `curl "http://192.168.0.138/debug/dial?number=17"` → série : route known → ringback → POST turn → **le combiné dit « Police secours, je vous écoute »**. (combiné décroché pour entendre, mute-on-hook oblige.)
|
||||
Expected : le PNJ parle.
|
||||
|
||||
- [ ] **Step 5 : Commit + PR submodule + bump** (branche `feat/plip-phone-stage2-fw`).
|
||||
|
||||
### Task 2.6 : Lander Stage 2 (gateway + déploiement)
|
||||
|
||||
- [ ] **Step 1 : PR gateway** (route + annuaire + sessions) → merge.
|
||||
- [ ] **Step 2 : Déployer le gateway** (scp `main.py` + `phone_directory.yaml` → `/home/electron/zacus-hub/...`, restart `zacus-hub-gateway`, `.bak` avant) — **nécessite autorisation explicite de déploiement prod**.
|
||||
- [ ] **Step 3 : Vérif prod** : `POST /v1/voice/turn {number:17,kind:greeting}` → WAV.
|
||||
|
||||
---
|
||||
|
||||
## STAGE 3 — Micro + whisper → conversation deux sens
|
||||
|
||||
### Task 3.1 : Vérifier la capture micro (dépendance hardware)
|
||||
|
||||
**Files:** aucun changement code tant que non diagnostiqué.
|
||||
|
||||
- [ ] **Step 1 : Test capture combiné DÉCROCHÉ**
|
||||
|
||||
Combiné branché + **décroché** (SLIC connecte l'audio). Parler dans le combiné pendant :
|
||||
```bash
|
||||
curl -s -XPOST "http://192.168.0.138/voice/capture?max_ms=4000" -o /tmp/cap.wav
|
||||
python3 - <<'PY'
|
||||
import wave,struct
|
||||
w=wave.open('/tmp/cap.wav'); n=w.getnframes(); d=w.readframes(n)
|
||||
s=struct.unpack('<%dh'%(len(d)//2), d)
|
||||
rms=(sum(x*x for x in s)/max(1,len(s)))**0.5
|
||||
print("samples",len(s),"rms",round(rms,1))
|
||||
PY
|
||||
```
|
||||
Expected : RMS nettement > 0 (voix captée). **Si RMS ≈ 0** : passer au Step 2.
|
||||
|
||||
- [ ] **Step 2 : Si zéros — basculer l'entrée ES8388 (LIN2/RIN2)**
|
||||
|
||||
Dans `es8388.c`, la config ADC sélectionne l'entrée (LINPUT1/RINPUT1). Si le SLIC est câblé sur l'entrée 2, changer la sélection sur LINPUT2/RINPUT2 (registre ADCCONTROL2/3 selon datasheet ES8388). Rebuild + flash + re-tester le Step 1. Documenter le pin réel. (Diagnostic possible via `GET /debug/regs` ajouté précédemment.)
|
||||
|
||||
- [ ] **Step 3 : Commit** (si changement entrée) : `git -C ESP32_ZACUS ... commit -am "fix(plip): ES8388 ADC input selection matches SLIC audio wiring"`.
|
||||
|
||||
### Task 3.2 : Endpoint STT vivant (whisper)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/zacus-gateway/main.py` (Settings `whisper_url` + `_transcribe`)
|
||||
- Test: `tests/gateway/test_transcribe.py`
|
||||
|
||||
- [ ] **Step 1 : Relancer whisper** : soit la voice-bridge sur studio (`/voice/transcribe` → whisper.cpp :8300), soit whisper.cpp seul. Vérifier `curl -F file=@/tmp/cap.wav <whisper_url>` → JSON `{text}`. Noter l'URL joignable depuis le gateway host. (Infra — hors code ; documenter la commande de démarrage.)
|
||||
|
||||
- [ ] **Step 2 : Test `_transcribe` (mock)** `tests/gateway/test_transcribe.py` :
|
||||
```python
|
||||
import sys, asyncio
|
||||
from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
|
||||
import main
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
def json(self): return {"text":"bonjour professeur"}
|
||||
class FakeHTTP:
|
||||
async def post(self,*a,**k): return FakeResp()
|
||||
def test_transcribe_returns_text():
|
||||
out = asyncio.run(main._transcribe(FakeHTTP(), b"RIFF....", "x.wav"))
|
||||
assert out == "bonjour professeur"
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → FAIL.
|
||||
|
||||
- [ ] **Step 4 : Implémenter `_transcribe`** (Settings `whisper_url: str = "http://studio:8200"`, endpoint `/voice/transcribe` multipart) :
|
||||
```python
|
||||
async def _transcribe(http, wav_bytes: bytes, filename: str = "speech.wav") -> str:
|
||||
files = {"file": (filename, wav_bytes, "audio/wav")}
|
||||
resp = await http.post(f"{settings.whisper_url.rstrip('/')}/voice/transcribe",
|
||||
files=files, timeout=60.0)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"stt HTTP {resp.status_code}")
|
||||
return (resp.json().get("text") or "").strip()
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → PASS.
|
||||
|
||||
- [ ] **Step 6 : Commit** : `git add ... && git commit -m "feat(gateway): whisper STT helper (_transcribe)"`.
|
||||
|
||||
### Task 3.3 : Voie `reply` de `/v1/voice/turn` (audio → STT → chat → TTS)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/zacus-gateway/main.py` (la route accepte le corps audio en `kind=reply`)
|
||||
- Test: `tests/gateway/test_voice_turn.py` (ajouter un cas reply)
|
||||
|
||||
- [ ] **Step 1 : Test reply (mock _transcribe + _chat_reply + _voice_tts_16k)**
|
||||
|
||||
Ajouter à `test_voice_turn.py` un test qui POST `kind=reply` avec un corps WAV (multipart ou raw body) et `number`/`session_id` en query, mocke `_transcribe`→"allo", `_chat_reply`→"oui ?", `_voice_tts_16k`→WAV ; assert 200, header `X-Zacus-Heard: allo`.
|
||||
|
||||
- [ ] **Step 2 : Lancer → échoue** (la route ignore encore l'audio). Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → le nouveau test FAIL.
|
||||
|
||||
- [ ] **Step 3 : Adapter la route** : pour `kind=reply`, lire le corps audio (le PLIP enverra le WAV en body brut + `number`/`session_id`/`kind` en query params, car le body est l'audio). Appeler `_transcribe(http, audio_bytes)` → `heard`, l'ajouter à l'historique, puis chat + TTS comme avant.
|
||||
|
||||
```python
|
||||
@app.post("/v1/voice/turn")
|
||||
async def voice_turn(request: Request, session_id: str, number: str,
|
||||
kind: str = "reply", _: None = Depends(require_token)):
|
||||
entry = PHONE_DIRECTORY.get(number)
|
||||
if not entry: raise HTTPException(404, f"numéro {number} non attribué")
|
||||
http = request.app.state.http; persona = entry.get("persona",""); voice = entry.get("voice")
|
||||
heard = ""
|
||||
if kind == "greeting":
|
||||
said = entry.get("greeting") or await _chat_reply(http, persona, [])
|
||||
else:
|
||||
audio = await request.body()
|
||||
try:
|
||||
heard = await _transcribe(http, audio)
|
||||
except Exception:
|
||||
heard = ""
|
||||
VOICE_SESSIONS.append(session_id, "user", heard or "(inaudible)")
|
||||
if not heard:
|
||||
said = "Allô ? Je vous entends très mal, pouvez-vous répéter ?"
|
||||
else:
|
||||
said = await _chat_reply(http, persona, VOICE_SESSIONS.history(session_id))
|
||||
VOICE_SESSIONS.append(session_id, "assistant", said)
|
||||
wav16,_d,_t = await _voice_tts_16k(http, said, voice)
|
||||
return Response(content=wav16, media_type="audio/wav",
|
||||
headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]})
|
||||
```
|
||||
(Note : `session_id`/`number`/`kind` passent en query params puisque le body porte l'audio. Mettre à jour le test greeting en conséquence : query params au lieu de json.)
|
||||
|
||||
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (tous).
|
||||
|
||||
- [ ] **Step 5 : Commit** : `git commit -m "feat(gateway): /v1/voice/turn reply path (audio→STT→chat→TTS)"`.
|
||||
|
||||
### Task 3.4 : Firmware — boucle LISTEN → turn(reply) → SPEAK
|
||||
|
||||
**Files:**
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECTED → boucle)
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/turn_client.c` (fonction reply : capture → POST WAV → reçoit WAV)
|
||||
|
||||
- [ ] **Step 1 : `turn_client_reply`** — capture le micro (réutilise l'API `audio_capture_begin/read_frame/end`), assemble un WAV en mémoire/SPIFFS, POST en body brut à `/v1/voice/turn?session_id=..&number=..&kind=reply`, écrit la réponse WAV dans `/spiffs/turn.wav`.
|
||||
|
||||
```c
|
||||
// turn_client.h : bool turn_client_reply(const char *sid, const char *number, const char *out_path);
|
||||
```
|
||||
(Capture : ~8 s max, VAD comme `/voice/capture`. Upload : `esp_http_client` POST streaming. Réutiliser le code de capture de `net.c::handle_voice_capture`.)
|
||||
|
||||
- [ ] **Step 2 : Boucle dans conversation.c**
|
||||
```c
|
||||
case ST_CONNECTED:
|
||||
// après la fin de lecture de l'accueil/réponse :
|
||||
if (audio_idle()){
|
||||
if (turn_client_reply(s_sid, dialer_current(), "/spiffs/turn.wav"))
|
||||
audio_play_async("/spiffs/turn.wav");
|
||||
// reste en CONNECTED → reboucle ; raccroché géré globalement
|
||||
}
|
||||
break;
|
||||
```
|
||||
(Ajouter `audio_idle()` si absent : true quand la queue est vide et aucune lecture en cours.)
|
||||
|
||||
- [ ] **Step 3 : Build + flash + test conversation deux sens** (combiné décroché, micro vérifié Task 3.1, whisper up) : décroché → numéro → accueil → parler → Zacus répond → reboucle → raccroché = fin.
|
||||
|
||||
- [ ] **Step 4 : Commit + PR submodule + bump.**
|
||||
|
||||
---
|
||||
|
||||
## STAGE 4 — 3615 Minitel (handshake + agent robotique)
|
||||
|
||||
### Task 4.1 : Sample handshake Minitel + lecture sur 3615
|
||||
|
||||
**Files:**
|
||||
- Create: `ESP32_ZACUS/plip_voice/main/minitel_tone.h` (PCM embarqué, court) OU pousser un WAV sur SPIFFS
|
||||
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (si `number=="3615"`, jouer le handshake avant GREET)
|
||||
|
||||
- [ ] **Step 1 : Obtenir le son handshake Minitel** : générer/approximer le handshake (séquence de tonalités modem) en WAV 16k court (~3 s), le pousser sur `/spiffs/minitel.wav` via `/game/file`, OU l'embarquer. (Sans fichier, approximer par une séquence de tones via `tones`/`audio_play_tone` : porteuse ~1300/2100 Hz alternées.)
|
||||
|
||||
- [ ] **Step 2 : Cas 3615 dans conversation.c** : à la connexion, si `dialer_current()=="3615"`, jouer `/spiffs/minitel.wav` (ou la séquence), PUIS enchaîner `turn_client_greeting` (l'annuaire a déjà la persona robotique + `minitel:true`). Le reste de la boucle est identique.
|
||||
|
||||
- [ ] **Step 3 : Build + flash + test** : composer 3615 → son modem Minitel → « Trois mille six cent quinze. Service en ligne… » → conversation robotique.
|
||||
|
||||
- [ ] **Step 4 : Commit + PR submodule + bump.**
|
||||
|
||||
### Task 4.2 : Test annuaire complet au banc
|
||||
|
||||
- [ ] **Step 1 :** Pour chaque numéro (12/15/17/18/3615/Zacus) : composer → accueil du bon PNJ (voix/persona). Numéro inconnu → occupé. Raccroché à tout moment → silence immédiat.
|
||||
- [ ] **Step 2 :** Mode dégradé : whisper down → l'accueil joue quand même, reply → « je vous entends mal » (fallback). Documenter.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture spec)
|
||||
|
||||
- **§2 Architecture (A, /v1/voice/turn unique)** → Tasks 2.4, 3.3 ✓
|
||||
- **§3 Séquence (tonalité/dialing/routing/ringback/busy/mute)** → Tasks 1.1, 1.4 ✓
|
||||
- **§4 Dialer (impulsions SHK + DTMF + debug)** → Tasks 1.2 (debug), 1.3 (impulsions) ✓ ; **DTMF Goertzel = non couvert explicitement** (voir note ci-dessous).
|
||||
- **§5 Annuaire (12/3615/15/17/18 + Zacus)** → Task 2.1 ✓
|
||||
- **§6 Contrat /v1/voice/turn (session_id/number/kind/audio, headers debug, /end)** → Tasks 2.4, 3.3 ✓
|
||||
- **§7 Services IA (ailiance chat+TTS, whisper STT)** → Tasks 2.3, 3.2 ✓
|
||||
- **§8 Dépendances (micro, whisper)** → Tasks 3.1, 3.2 ✓
|
||||
- **§9 Mode dégradé** → Task 4.2 Step 2 ✓
|
||||
- **§10 Erreurs (STT vide, réseau, raccroché, inconnu, cap)** → Tasks 2.4 (404 inconnu), 3.3 (fallback STT vide), 1.4 (raccroché/busy) ✓
|
||||
- **§12 Build incrémental** → Stages 1-4 ✓
|
||||
- **§13 Tests** → tests gateway pytest (2.x, 3.x) + tests banc firmware ✓
|
||||
|
||||
**Gap identifié — DTMF Goertzel** : le spec liste le DTMF comme 2ᵉ source de numérotation, mais le plan ne le détaille pas (seuls impulsions + debug sont couverts). **Décision** : le DTMF est repoussé en option post-Stage-1 (le cadran à impulsions + debug couvrent la cible « vintage à cadran » prioritaire). À ajouter comme Task 1.6 ultérieure si un téléphone à touches est utilisé : décodage Goertzel des 8 fréquences sur les frames de `audio_capture_read_frame`, derrière `CONFIG_PLIP_DIAL_DTMF`. Ce report est cohérent avec la cible (téléphone à cadran) confirmée au brainstorming.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Freenove Media Kit — énigmes locales (P1 son, P3 QR) — Design Spec
|
||||
|
||||
> Statut : design validé en brainstorming (2026-06-10), à relire avant plan
|
||||
> d'implémentation. Approche retenue : **A — composant `local_puzzles`**.
|
||||
|
||||
## Contexte & objectif
|
||||
|
||||
La Freenove **ESP32-S3 Media Kit v1.2** (module **N16R8** : 16 Mo flash,
|
||||
8 Mo PSRAM octale, caméra **OV2640**, micro I2S MEMS, haut-parleur, SD,
|
||||
batterie) exécute le firmware master `idf_zacus`. Aujourd'hui le master est un
|
||||
orchestrateur headless (ESP-NOW hub, `game_endpoint`, `voice_pipeline`,
|
||||
`npc_engine`, relay scénario), et les énigmes sont des **nœuds ESP32 dédiés**
|
||||
(`puzzles/p1,p5,p6,p7`).
|
||||
|
||||
**Objectif** : faire de la Freenove un **master portable tout-en-un** qui, en
|
||||
plus d'orchestrer, prend en charge **directement** deux énigmes via ses
|
||||
capteurs intégrés :
|
||||
|
||||
- **P3 — QR Treasure** : scanner N QR codes dans le bon ordre (caméra OV2640).
|
||||
- **P1 — Séquence sonore** : valider une mélodie reproduite par le joueur
|
||||
(micro I2S).
|
||||
|
||||
Les énigmes **P5 morse / P6 NFC / P7 coffre**, ainsi que **BOX-3** et **PLIP**,
|
||||
restent des nœuds ESP-NOW : aucune modification de leur firmware.
|
||||
|
||||
### Non-objectifs
|
||||
|
||||
- Pas de migration de P5/P6/P7 sur la Freenove (périphériques externes non
|
||||
« média » ; hors scope de ce spec).
|
||||
- Pas de suppression du protocole ESP-NOW ni du `game_endpoint` existant.
|
||||
- Pas de refonte du `npc_engine` / `voice_pipeline` au-delà du strict
|
||||
nécessaire pour partager le micro.
|
||||
|
||||
## Architecture — `idf_zacus/components/local_puzzles/`
|
||||
|
||||
Nouveau composant, trois unités à responsabilité unique + un point d'entrée
|
||||
commun. Chaque unité est testable et remplaçable indépendamment.
|
||||
|
||||
### `mic_broker`
|
||||
|
||||
Possède le canal I2S micro (RX) et distribue les frames PCM16 16 kHz à **un
|
||||
seul** consommateur actif, selon un mode explicite :
|
||||
|
||||
```
|
||||
typedef enum { MIC_IDLE, MIC_NPC_LISTEN, MIC_P1_SOUND } mic_mode_t;
|
||||
void mic_broker_set_mode(mic_mode_t mode);
|
||||
// Un consommateur s'enregistre ; il ne reçoit des frames que dans son mode.
|
||||
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx);
|
||||
```
|
||||
|
||||
- Résout la **contention micro** par construction : NPC et P1 ne tournent
|
||||
jamais en même temps.
|
||||
- `voice_pipeline` est refactoré pour **tirer ses frames du broker**
|
||||
(`MIC_NPC_LISTEN`) au lieu de posséder l'I2S directement. Changement minimal :
|
||||
le broker encapsule l'init I2S existante du `voice_pipeline`.
|
||||
- Le mode est piloté par l'état de scénario (voir Flux de données).
|
||||
|
||||
### `qr_puzzle`
|
||||
|
||||
- Init **`esp32-camera`** (composant `espressif/esp32-camera`) avec le pin-map
|
||||
**Freenove ESP32-S3 Media Kit** (OV2640, voir Annexe pin-map).
|
||||
- Décodage QR via **`quirc`** (bibliothèque C, intégrée en composant — **pas**
|
||||
la lib Arduino `lib/ESP32QRCodeReader_vendor`, incompatible IDF).
|
||||
- Boucle de scan active **uniquement** quand P3 est en cours ; accumule les
|
||||
codes lus et valide la **séquence** contre l'ordre attendu (réutilise la
|
||||
logique `correct_order` du bloc P3 de l'atelier).
|
||||
- Caméra **dé-initialisée** hors P3 (`esp_camera_deinit`) — économie batterie
|
||||
et libération PSRAM.
|
||||
|
||||
### `sound_puzzle`
|
||||
|
||||
- Consommateur `MIC_P1_SOUND` du broker.
|
||||
- Détection d'attaque/hauteur (énergie + estimation de fréquence, fenêtres
|
||||
glissantes) ; valide la **séquence de notes** reproduite contre la mélodie
|
||||
attendue (logique reprise du nœud `puzzles/p1_sequence_sonore`).
|
||||
- Tolérance/seuils paramétrables (difficulté).
|
||||
|
||||
### `local_puzzle_report()`
|
||||
|
||||
```
|
||||
void local_puzzle_report(uint8_t puzzle_id, const uint8_t code_fragment[4]);
|
||||
```
|
||||
|
||||
Point d'entrée commun : **synthétise un événement « puzzle résolu »** dans
|
||||
`game_endpoint`, identique au chemin du handler `MSG_PUZZLE_SOLVED` des nœuds
|
||||
ESP-NOW. Le master traite ainsi énigmes **locales et distantes de façon
|
||||
uniforme** (assemblage du code, ligne NPC, LED, scoring).
|
||||
|
||||
## Flux de données
|
||||
|
||||
```
|
||||
scénario/état de jeu
|
||||
│ active P1 ou P3 (étape courante)
|
||||
▼
|
||||
local_puzzles ──arme le module──► qr_puzzle / sound_puzzle
|
||||
│ règle le mode micro (mic_broker_set_mode)
|
||||
▼
|
||||
succès (séquence/mélodie correcte)
|
||||
│
|
||||
▼
|
||||
local_puzzle_report(puzzle_id, code_fragment)
|
||||
│
|
||||
▼
|
||||
game_endpoint ──► même aval que les énigmes ESP-NOW
|
||||
(code assemblé, NPC, LED, score)
|
||||
```
|
||||
|
||||
## Configuration & compatibilité
|
||||
|
||||
Chaque énigme est déclarée **`local` vs `remote`** (dans `boards.yaml` master
|
||||
ou le scénario). Le **même firmware** fonctionne que P1/P3 soient locaux
|
||||
(Freenove) ou en nœud ESP-NOW. Le nœud `puzzles/p1_sequence_sonore` devient
|
||||
**optionnel** : repli si la caméra/micro local est indisponible.
|
||||
|
||||
## Gestion des erreurs & batterie
|
||||
|
||||
- **Échec init caméra** → P3 retombe sur le nœud ESP-NOW s'il est déclaré,
|
||||
sinon log d'erreur et P3 inactif (le reste du jeu continue).
|
||||
- **Contention micro** → impossible (mode exclusif du broker).
|
||||
- **Batterie** → caméra dé-initialisée hors P3 ; micro en `MIC_IDLE` quand ni
|
||||
NPC ni P1 actifs. Pas de capture inutile.
|
||||
- **PSRAM** → 8 Mo octale (N16R8) : framebuffer caméra (QVGA/VGA suffisant pour
|
||||
QR) + quirc large ; budget confortable.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Logique pure (host)** : validateur de séquence QR (`correct_order`) et
|
||||
validateur de mélodie (séquence de notes + tolérance) — tests unitaires sans
|
||||
matériel.
|
||||
- **Smoke hardware** : scanner un QR connu → `qr_puzzle` rapporte la bonne
|
||||
valeur ; jouer un ton/mélodie → `sound_puzzle` valide ; vérifier que
|
||||
`local_puzzle_report` déclenche le même aval qu'un `MSG_PUZZLE_SOLVED` ESP-NOW.
|
||||
- **Régression** : NPC vocal toujours fonctionnel (le broker n'a pas cassé le
|
||||
`voice_pipeline`).
|
||||
|
||||
## Découpage suggéré (pour le plan)
|
||||
|
||||
1. `mic_broker` + refacto `voice_pipeline` pour passer par le broker (NPC
|
||||
inchangé fonctionnellement). **Indépendant, testable seul.**
|
||||
2. `qr_puzzle` (caméra + quirc + validateur de séquence) + `local_puzzle_report`
|
||||
branché sur `game_endpoint`. **Livrable visible : P3 sur la Freenove.**
|
||||
3. `sound_puzzle` (détection mélodie via broker). **Livrable : P1 sur la
|
||||
Freenove.**
|
||||
4. Flag de config `local|remote` par énigme + repli ESP-NOW.
|
||||
|
||||
Chaque étape compile et est flashable indépendamment.
|
||||
|
||||
## Annexe — pin-map caméra Freenove ESP32-S3 (OV2640)
|
||||
|
||||
À confirmer sur le schéma **Media Kit v1.2** avant implémentation (valeurs
|
||||
Freenove ESP32-S3-WROOM CAM standard) :
|
||||
|
||||
| Signal | GPIO | Signal | GPIO |
|
||||
|---|---|---|---|
|
||||
| PWDN | -1 | HREF | 7 |
|
||||
| RESET | -1 | PCLK | 13 |
|
||||
| XCLK | 15 | D7 | 16 |
|
||||
| SIOD (SDA) | 4 | D6 | 17 |
|
||||
| SIOC (SCL) | 5 | D5 | 18 |
|
||||
| VSYNC | 6 | D4 | 12 |
|
||||
| D3 | 10 | D2 | 8 |
|
||||
| D1 | 9 | D0 | 11 |
|
||||
|
||||
**Micro I2S MEMS** : pins à confirmer sur le schéma Media Kit v1.2 (le
|
||||
`voice_pipeline` actuel connaît déjà la config micro du master — la réutiliser
|
||||
via le broker plutôt que la redéfinir).
|
||||
|
||||
## Inconnues résiduelles (à lever au début du plan)
|
||||
|
||||
1. Pins exactes du **micro I2S** sur le Media Kit v1.2 (récupérer depuis la
|
||||
config `voice_pipeline` existante).
|
||||
2. Confirmer **OV2640** (vs OV5640) et la résolution minimale fiable pour le
|
||||
décodage QR à la distance de jeu.
|
||||
3. Disponibilité du composant **`quirc`** en registre IDF, sinon vendoring.
|
||||
@@ -0,0 +1,78 @@
|
||||
# ESP-NOW commandes & événements — design v1 (2026-06-11)
|
||||
|
||||
## Problème
|
||||
|
||||
Les 4 blocs Blockly ESP-NOW compilent vers des actions IR
|
||||
(`espnow_register_peer`, `espnow_send`, `espnow_broadcast`, `espnow_wait`)
|
||||
que **personne n'exécute** : le gateway ne sait que relayer des IR complets
|
||||
(`/game/scenario/relay`), et le firmware master ignore les actions des steps.
|
||||
La pile radio existe pourtant (`ESP32_ZACUS/lib/scenario_mesh/`, partagée
|
||||
master/annexes) mais ne transporte que des chunks d'IR.
|
||||
|
||||
## Décisions de cadrage (validées)
|
||||
|
||||
1. **Rôle** : commandes ET événements — le scénario commande les annexes et
|
||||
attend leurs événements.
|
||||
2. **Orchestration** : **master autonome** — le firmware Freenove
|
||||
(`idf_zacus`) exécute lui-même les actions espnow des steps ; pas de
|
||||
dépendance au gateway pendant la partie.
|
||||
3. **Périmètre** : bout-en-bout avec la BOX-3 (`box3_voice`) comme annexe.
|
||||
4. **Sémantique `espnow_wait`** : action **bloquante** (événement ou timeout,
|
||||
puis le déroulé continue ; timeout → log, pas d'échec de step).
|
||||
5. **Côté annexe** : handler **ouvert** — la commande est une chaîne opaque
|
||||
passée à un callback enregistrable ; l'annexe répond l'événement de son
|
||||
choix. Démo livrée : `ping` → `pong`, branchement optionnel `stim:*`.
|
||||
|
||||
## Architecture (approche A retenue)
|
||||
|
||||
### 1. Protocole — `lib/scenario_mesh` (composant partagé)
|
||||
|
||||
- Les frames gagnent un **type** : `IR_CHUNK` (existant), `CMD`, `EVT`.
|
||||
- Payload CMD/EVT : chaîne UTF-8 opaque ≤ 200 octets.
|
||||
- API additive :
|
||||
- `esp_err_t scenario_mesh_send_text(const uint8_t mac[6], uint8_t kind, const char *text);`
|
||||
- `void scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb);`
|
||||
(callback : kind, MAC source, texte ; dispatché hors contexte WiFi via la
|
||||
queue/tâche existante).
|
||||
- Pas d'ack applicatif en v1 : statut d'envoi radio natif + confirmation
|
||||
logique par `wait`.
|
||||
|
||||
### 2. Master — `idf_zacus`
|
||||
|
||||
- À l'application d'un step, exécution séquentielle des actions `espnow_*`
|
||||
(les autres kinds restent ignorés) :
|
||||
- `espnow_register_peer {alias, mac}` → `scenario_mesh_register_peer`.
|
||||
- `espnow_send {peer, command}` → résolution alias → frame CMD.
|
||||
- `espnow_broadcast {command}` → frame CMD vers FF:FF:FF:FF:FF:FF.
|
||||
- `espnow_wait {command, timeout_ms}` → sémaphore + timeout, débloqué par
|
||||
un EVT dont le texte égale `command` ; timeout → `ESP_LOGW` et on
|
||||
continue.
|
||||
- Endpoint debug : `POST /game/espnow/cmd {peer|broadcast, command}` pour
|
||||
tester le tuyau sans scénario.
|
||||
|
||||
### 3. Annexe — `box3_voice`
|
||||
|
||||
- Enregistre le callback texte : sur CMD reçue, passe la chaîne au handler
|
||||
ouvert du firmware.
|
||||
- Handler de démo : `ping` → répond EVT `pong` au MAC source ; préfixe
|
||||
`stim:` → déclenche le générateur de stimulus existant (`melody`, `qr`)
|
||||
puis répond `done:<cmd>` (automatisera les tests physiques P1/P3).
|
||||
|
||||
### 4. Tests
|
||||
|
||||
- Export Blockly→IR : déjà couvert (`test_blocks_export_espnow.py`).
|
||||
- Master seul : `POST /game/espnow/cmd` vers un peer absent → statut radio
|
||||
loggé ; step avec `espnow_wait` sans annexe → timeout propre.
|
||||
- Bout-en-bout 2 cartes : step `espnow_send box3 "ping"` +
|
||||
`espnow_wait "pong" 10s` → logs série des deux côtés, wait débloqué.
|
||||
|
||||
## Hors scope v1
|
||||
|
||||
Retries/ack applicatif, chiffrement, événements→transitions IR, file
|
||||
d'événements persistante, exécution des autres kinds d'actions par le master.
|
||||
|
||||
## Découpage
|
||||
|
||||
1. **Commit 1** : protocole CMD/EVT + exécuteur master + endpoint debug,
|
||||
testé sur la Freenove seule (timeout, statuts radio).
|
||||
2. **Commit 2** : handler BOX-3 (`ping`/`pong`, `stim:*`) + test 2 cartes.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Refonte UI Atelier — « Console du Professeur Zacus »
|
||||
|
||||
**Date** : 2026-06-13
|
||||
**Statut** : design approuvé (split vertical)
|
||||
**Périmètre** : `frontend-v3/apps/atelier`
|
||||
|
||||
## 1. Intention
|
||||
|
||||
Refondre complètement l'UI de l'atelier d'authoring Zacus :
|
||||
- **retirer** toute la simulation 3D (R3F/three) ;
|
||||
- adopter un **langage visuel néo-rétro « labo du professeur »** (palette Workbench du firmware) ;
|
||||
- **conserver Blockly** comme cœur de l'édition, refondre le layout autour ;
|
||||
- remplacer la colonne 3D par un **Banc d'essai** : aperçu écran device fidèle **+** vue flux du scénario (split vertical, les deux visibles).
|
||||
|
||||
## 2. Layout cible
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ ▦ ZACUS · ATELIER [Menu][Config][Déployer][✦Assets] ← barre labo (Orbitron)
|
||||
├──────────────────────────┬────────────────────┤
|
||||
│ Éditeur Blockly │ BANC D'ESSAI │
|
||||
│ (thème Workbench) 55% │ ┌── Écran device ──┐ │ aperçu LCD ST7796 fidèle
|
||||
│ │ └──────────────────┘ │ de l'étape sélectionnée
|
||||
│ │ ┌── Flux scénario ─┐ │ graphe d'étapes/pivots
|
||||
│ │ └──────────────────┘ │ clic nœud → écran + bloc
|
||||
├──────────────────────────┴────────────────────┤
|
||||
│ Console validation (schema/compile) · 25% │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Colonne droite = `WorkbenchPane`, **split vertical** : `DeviceScreen` (haut) + `FlowGraph` (bas), via `react-resizable-panels` (déjà utilisé).
|
||||
- `⌘B` : bascule l'affichage du Banc d'essai (remplace l'ancien toggle 3D).
|
||||
|
||||
## 3. Langage visuel (Workbench)
|
||||
|
||||
Repris 1:1 de `ESP32_ZACUS/.../display_ui.cpp` :
|
||||
- Palette : fond bleu profond `#0055AA` → quasi-noir en surfaces ; accent `#FF8800` (orange Workbench) ; valeurs `#FFFFFF` ; labels `#AAAAAA`.
|
||||
- Fonts : **Orbitron** (titres, chrome) + **IBM Plex Mono** (données, labels). Chargées localement (pas de CDN bloquant) avec fallbacks.
|
||||
- Chrome : barre-titre façon device, cadres de panneaux étiquetés, LEDs de statut (gateway / cartes), légère texture grille/scanline.
|
||||
- **Blockly** reçoit un thème custom assorti (API `Blockly.Theme`).
|
||||
|
||||
## 4. Composants
|
||||
|
||||
### Supprimés
|
||||
- `components/StagePane.tsx`
|
||||
- `scene/*` : NpcBubble, PuzzleStation, PuzzleStations, Room, RoomScene, RtcPhone
|
||||
- `puzzles/*` : P1Sound, P5Morse, P6Symbols, P7Coffre
|
||||
- `modes/*` : DemoMode, SandboxMode, TestMode
|
||||
- `stores/simStore.ts`
|
||||
- deps `three`, `@types/three`, `@react-three/fiber`, `@react-three/drei`
|
||||
- `manualChunks` three/r3f dans `vite.config.ts`
|
||||
- Console : onglets de **modes** (liés à la sim) → la console ne garde que la validation.
|
||||
|
||||
### Nouveaux
|
||||
- `components/WorkbenchPane.tsx` — split vertical (Écran / Flux).
|
||||
- `components/DeviceScreen.tsx` — réutilise le rendu fidèle de `ScreenPreview` (extrait de `ScreenEditorPanel`/`lib/screenSpec.ts`), branché sur la `scene` de l'étape sélectionnée.
|
||||
- `components/FlowGraph.tsx` — nœuds = steps du scénario compilé (IR), arêtes = transitions/pivots ; clic → sélection d'étape.
|
||||
- `stores/selectionStore.ts` — état léger : `selectedStepId`.
|
||||
|
||||
### Re-skinnés
|
||||
- `Layout.tsx`, `MenuBar.tsx`, panneaux flottants (`GameConfigPanel`, `DeployPanel`, `AssetsPanel`), `main.css`, `BlocklyWorkspace.tsx` (thème).
|
||||
|
||||
## 5. Flux de données
|
||||
|
||||
- `FlowGraph` lit les steps depuis le scénario courant. Source : l'IR de `runtimeStore` (déjà calculé via le live-diff) ; à défaut, parse l'export YAML courant.
|
||||
- Sélection d'un nœud → `selectionStore.selectedStepId` → `DeviceScreen` affiche la `scene` de ce step (title/subtitle/symbol/effect) ; option : focaliser le bloc Blockly correspondant.
|
||||
- `DeviceScreen` se met aussi à jour quand l'IR change (édition live).
|
||||
|
||||
## 6. Découpage d'implémentation (3 phases)
|
||||
|
||||
- **A — Retrait 3D** : supprimer sim/3D, corriger `Layout`/`ConsolePane`/`App`, retirer les deps + `manualChunks`, mettre à jour les hooks e2e (`window.__atelierStores` perd `sim`). Pure soustraction — dérisque, build doit rester vert.
|
||||
- **B — Thème Workbench** : palette/fonts/chrome/`main.css` + thème Blockly + re-skin barre & panneaux.
|
||||
- **C — Banc d'essai** : `WorkbenchPane` + `DeviceScreen` + `FlowGraph` + `selectionStore` + wiring.
|
||||
|
||||
## 7. Tests & risques
|
||||
|
||||
- Gate à chaque phase : `pnpm exec tsc --noEmit` + `pnpm exec vite build` verts.
|
||||
- Specs e2e pilotant la sim (`__atelierStores.sim`, modes) : à nettoyer/retirer en phase A.
|
||||
- Risque principal : `FlowGraph` interactif (le morceau le plus consistant) — commencer simple (liste ordonnée de nœuds cliquables + arêtes de transition), enrichir ensuite.
|
||||
- `DeviceScreen` dépend de la présence d'une `scene` sur le step ; fallback = afficher step_id/code comme le firmware.
|
||||
|
||||
## 8. Hors périmètre
|
||||
|
||||
- Refonte de l'édition par blocs (Blockly conservé tel quel, juste thémé).
|
||||
- Playtest texte / runtime pas-à-pas (envisagé mais non retenu pour cette refonte).
|
||||
- `apps/dashboard` (cette refonte concerne `apps/atelier`).
|
||||
@@ -0,0 +1,88 @@
|
||||
# DSL Blockly honnête & couplé hardware — Design
|
||||
|
||||
**Date** : 2026-06-14
|
||||
**Statut** : design approuvé (hybride : honnête + roadmap ; priorités binding carte / média SD / transitions réelles)
|
||||
**Périmètre** : `frontend-v3/packages/shared/blockly`, `frontend-v3/apps/atelier`, `tools/zacus-gateway`, `tools/scenario`
|
||||
|
||||
## 1. Problème
|
||||
|
||||
Le DSL Blockly actuel (64 blocs, source unique `frontend-v3/packages/shared/blockly/zacus_blocks.mjs`) est ~70 % **fantôme** : la plupart des blocs sont validés par le gateway puis **ignorés par le firmware**. Le firmware Freenove master sait réellement faire :
|
||||
- **Énigmes** : `qr` + `sound` uniquement (`puzzle_binding.h` enum `PB_NONE/PB_QR/PB_SOUND`) ;
|
||||
- **Écran** : step-extra `scene` (title≤47 / subtitle≤63 / symbol≤15 / effect∈pulse|glitch|gyro|none) ;
|
||||
- **Média** : WAV + MP3 par **chemin** (`media_manager`, `POST /game/media/play {path}`), résolu sur `/sdcard/...` ou `/littlefs/...` ;
|
||||
- **Relais ESP-NOW** : IR chunké (`/game/scenario/relay {peers,ir}`) + canal texte libre ≤200 o (sans consommateur structuré) ;
|
||||
- **Voix** : déléguée au voice-bridge :8200 ;
|
||||
- **PAS de moteur de transitions on-device** : le firmware est piloté pas-à-pas par `POST /game/step {step_id}` ; la logique vit côté gateway/web.
|
||||
|
||||
Sans support firmware : servo, relais, buzzer, NFC, morse/radio/LED/keypad, cartes M5/BOX-3/PLIP, couche GM. Non exposées en blocs alors que réelles : **binding à une carte précise**, **chemin média SD réel**, **transitions `button/serial/audio_done/unlock/espnow`**, **branchement sur l'état des énigmes** (`puzzle_state`).
|
||||
|
||||
## 2. Intention
|
||||
|
||||
Rendre le DSL **honnête** (refléter ce que la carte fait) et **réellement couplé au hardware**, en gardant les blocs non encore supportés comme **roadmap balisée** (hybride). Priorités : binding carte, média SD réel, transitions réelles.
|
||||
|
||||
## 3. Architecture cible
|
||||
|
||||
### 3.1 Deux voies de blocs
|
||||
- **✅ RÉELLE** (exécutée on-device) : `sceneStart/End/Goto/Branch`, `scene`, `puzzleQR`, `puzzleSound`, média réel, relais ESP-NOW, `npcSay`/`npcIntentMatch`, transitions réelles.
|
||||
- **🔬 HARDWARE FUTUR** (validée, non exécutée) : servo, relais, buzzer, NFC, `puzzleLED/Radio/Morse/NFC/Coffre`, M5/BOX-3/PLIP, GM. Catégorie(s) Blockly distincte(s), couleur/badge « non câblé », et un avertissement non bloquant à la compilation (warning, pas erreur).
|
||||
|
||||
La distinction vit dans `zacus_blocks.mjs` : un set `ZACUS_REAL_KINDS` (firmware-backed) ; `ZACUS_TOOLBOX_CATEGORIES` réorganisé pour grouper réel vs futur. La toolbox de l'atelier (`apps/atelier/src/components/toolbox.ts`) est générée depuis ces catégories — donc se met à jour automatiquement.
|
||||
|
||||
### 3.2 Binding carte
|
||||
- Cartes connues : `master`, `box3`, `plip`, `p7` (cf. `tools/zacus-gateway/boards.yaml`).
|
||||
- **Champ `board`** (défaut `master`) sur les blocs énigme (`puzzleQR`, `puzzleSound`) et un bloc contexte **`hwOnBoard`** (« ▸ sur la carte X { … } », c-block) qui cible les actions/cues qu'il contient.
|
||||
- Compilation : le `board` est porté sur le step (`step.board`) et/ou l'action (`action.board`). Le gateway route :
|
||||
- énigme/scénario d'une carte ≠ master → `POST /game/scenario/relay {peers:[board], ir}` ;
|
||||
- cue média → `POST /game/media/play` **sur l'IP/relais de la bonne carte**.
|
||||
- `board` validé contre `boards.yaml` (gateway) ; carte inconnue → erreur de compile claire.
|
||||
|
||||
### 3.3 Média réel (SD)
|
||||
- Bloc **`mediaPlay`** : `path` (chemin SD réel, ex. `zacus/<board>/<file>.wav`, piochable dans le pool stagé P4) + `board` (hérité de `hwOnBoard` si présent) + `loop?`.
|
||||
- Remplace l'usage opaque de `hwSoundPlay`(`asset`). Compile → action `sound_play{path,board}`.
|
||||
- Le gateway expose le routage : action `sound_play` d'un step joué → `POST http://<ip(board)>/game/media/play {path}` (résolution IP/relais via `boards.yaml`). Cohérent avec le staging P4 (`/sdcard/zacus/{board}/`).
|
||||
|
||||
### 3.4 Transitions réelles
|
||||
- Nouveaux blocs « **Quand …** » (hat/stack attachés à un step) produisant une `RuntimeTransition` avec le vrai `event_type` :
|
||||
- `onButton(button)` → `event_type: button`, `event_name: <button>` ;
|
||||
- `onAudioDone()` → `audio_done` ;
|
||||
- `onUnlock(source)` → `unlock` ;
|
||||
- `onSerial(token)` → `serial` ;
|
||||
- `onEspnow(command)` → `espnow` ;
|
||||
- `onTimer(seconds)` → `timer` (`after_ms`) — existe déjà via `logicTimer`, à aligner ;
|
||||
- `onPuzzleSolved(puzzleId)` → branchement sur `puzzle_state` (résolu) ; `onCodeAssembled(code)` → idem code assemblé.
|
||||
- Chaque bloc « Quand » porte une cible `target` (step id). Compilation dans `blocks_to_runtime3.py` : émettre la transition avec `event_type`/`event_name`/`target_step_id` corrects (aujourd'hui seuls `action`/`timer` sortent). `runtime3_common.py::EVENT_TYPES` déjà = `{button,serial,timer,audio_done,unlock,espnow,action}` → pas d'extension du schéma, juste de l'émission.
|
||||
|
||||
### 3.5 Contrat executor annexe (roadmap, doc seulement)
|
||||
- Spécifier un **schéma CMD ESP-NOW structuré** (JSON compact ≤200 o) remplaçant le texte libre : `{op, args}` (ex. `{"op":"play","args":{"path":"…"}}`, `{"op":"led","args":{"pattern":"…"}}`). Documente le contrat (`docs/` ou spec firmware) pour que les cartes annexes (M5/BOX-3/PLIP) le décodent **plus tard** ; aucun firmware annexe écrit ici.
|
||||
|
||||
## 4. Composants & fichiers
|
||||
|
||||
| Fichier | Changement |
|
||||
|---|---|
|
||||
| `frontend-v3/packages/shared/blockly/zacus_blocks.mjs` | `ZACUS_REAL_KINDS` ; champ `board` sur puzzles ; blocs `hwOnBoard`, `mediaPlay`, `onButton/onAudioDone/onUnlock/onSerial/onEspnow/onPuzzleSolved/onCodeAssembled` ; réorg `ZACUS_TOOLBOX_CATEGORIES` (réel vs futur) |
|
||||
| `frontend-v3/apps/atelier/src/components/toolbox.ts` | (auto depuis catégories) — vérifier le rendu 2 voies |
|
||||
| `frontend-v3/apps/atelier/src/lib/yaml-export.ts` | sérialiser les nouveaux params (`board`, `path`, event targets) |
|
||||
| `tools/zacus-gateway/blocks_to_runtime3.py` | compiler `board`, `mediaPlay`→`sound_play{path,board}`, blocs `onX`→transitions `event_type` réelles |
|
||||
| `tools/scenario/runtime3_common.py` | validation `board`∈boards, `sound_play.path`, warning « bloc hardware-futur » |
|
||||
| `tools/zacus-gateway/main.py` | routage : `sound_play`/relais vers la bonne carte (IP/relais via `boards.yaml`) ; endpoint de « run d'un step » si nécessaire |
|
||||
| `docs/…/esp-now-cmd-contract.md` | contrat CMD structuré (roadmap) |
|
||||
|
||||
## 5. Découpage d'implémentation (phases)
|
||||
- **D1** — Réorg honnête : `ZACUS_REAL_KINDS` + catégories toolbox 2 voies + badge/warning compile « hardware futur ». Web + compile, risque nul (aucune sémantique cassée). Régénérer le catalogue hub (`tools/dev/sync_blockly_catalog.sh` si présent).
|
||||
- **D2** — Binding carte : champ `board` + bloc `hwOnBoard` + compile (`step.board`/`action.board`) + validation `boards.yaml` + routage gateway relais.
|
||||
- **D3** — Média réel SD : bloc `mediaPlay(path,board,loop)` + compile `sound_play{path,board}` + routage `/game/media/play` sur la bonne carte.
|
||||
- **D4** — Transitions réelles : blocs `onX` → `RuntimeTransition` avec `event_type` réels + branchement `puzzle_state`.
|
||||
- **D5** — Doc contrat executor annexe ESP-NOW.
|
||||
|
||||
## 6. Tests & risques
|
||||
- **Pipeline** : `make content-checks` / `make runtime3-*`, `python3 -m unittest discover -s tests`, et un round-trip blocs→IR validé (compile + validators) à chaque phase.
|
||||
- **Frontend** : `tsc --noEmit` + `vite build` verts (toolbox).
|
||||
- **Parité TS↔PY** : `yaml-export.ts` (serialize) et `blocks_to_runtime3.py` (compile) doivent rester en miroir — tout nouveau param sérialisé côté TS doit être lu côté PY.
|
||||
- **E2E hardware** : routage gateway→carte (relais, media/play) testable seulement avec les cartes — hardware-gated ; sinon stub/contrat.
|
||||
- **Risque principal** : la double implémentation TS (sérialisation/preview) + Python (compile/validation) — un champ ajouté d'un seul côté = silencieusement perdu. Chaque phase ajoute le champ des DEUX côtés + un test de parité.
|
||||
|
||||
## 7. Hors périmètre
|
||||
- Profils de groupe (TECH/NON_TECH/MIXED/BOTH) — écartés pour cette itération.
|
||||
- Écriture de firmware annexe (M5/BOX-3/PLIP) — seulement le contrat documenté (D5).
|
||||
- Implémentation firmware des énigmes manquantes (morse/NFC/…) — restent « hardware futur ».
|
||||
- Le graphe spatial FlowGraph (déjà fait, branche séparée).
|
||||
@@ -0,0 +1,158 @@
|
||||
# PLIP — Téléphone conversationnel France Télécom — Design
|
||||
|
||||
**Date** : 2026-06-14
|
||||
**Statut** : design validé, prêt pour plan d'implémentation
|
||||
**Carte** : PLIP (téléphone rétro), `ESP32_ZACUS/plip_voice/`, ESP32-A1S Audio Kit + SLIC classe AG1171, codec ES8388, IP 192.168.0.138
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Faire du PLIP un vrai **téléphone France Télécom interactif** dans l'escape room « Mystère du Professeur Zacus » : le joueur décroche, entend la tonalité, **compose un numéro** sur le cadran, et tombe sur un PNJ vocal (Professeur Zacus ou un service d'époque) avec qui il **converse** (reconnaissance vocale → réponse IA en personnage). Raccroché = fin + silence.
|
||||
|
||||
## 2. Architecture — Approche retenue (A)
|
||||
|
||||
Le **firmware PLIP possède la machine à états** (la « boucle » téléphonique) ; la **gateway** expose **un endpoint stateless-par-tour** qui enchaîne STT → LLM → TTS. Le PLIP ne fait ni TLS, ni prompt, ni resample.
|
||||
|
||||
```
|
||||
COMBINÉ PLIP (firmware = machine à états)
|
||||
raccroché → décroché → TONALITÉ → numérotation → routage annuaire
|
||||
→ ringback → accueil PNJ → [ ÉCOUTE(VAD) → /v1/voice/turn → JOUE ]* → raccroché→IDLE+mute
|
||||
│
|
||||
└─ HTTP simple (plain, LAN) ─────────────► ZACUS-GATEWAY /v1/voice/turn (stateless/tour)
|
||||
stt(whisper) → chat(ailiance + persona + historique) → tts(ailiance) → resample 16k
|
||||
│ │ │
|
||||
whisper :8300 gateway.ailiance.fr /v1/chat/completions .../v1/audio/speech
|
||||
(À RELANCER) ✅ Ministral & co ✅ tts-1
|
||||
```
|
||||
|
||||
**Frontière** :
|
||||
- **PLIP firmware** : machine à états, `session_id` au décroché, capture (réutilise `/voice/capture` interne), POST le WAV, joue la réponse, reboucle ; raccroché = abort + mute (réutilise le mute-on-hook existant). Aucun TLS / prompt / resample sur l'ESP32.
|
||||
- **Gateway** : pipeline IA d'un tour + **historique court par `session_id`** (mémoire, TTL 10 min). Persona + voix + annuaire = côté serveur, éditables sans reflasher.
|
||||
|
||||
**Décision** : **un seul appel `/v1/voice/turn` par tour** (PLIP envoie le WAV capté, reçoit le WAV de réponse) plutôt que 3 appels stt/chat/tts séparés → firmware bien plus simple, le PLIP garde la maîtrise du séquencement (quand écouter, stop VAD, jouer, reboucler, abort).
|
||||
|
||||
## 3. Séquence téléphonique (machine à états)
|
||||
|
||||
```
|
||||
IDLE (raccroché, PA coupé, muet)
|
||||
└─ DÉCROCHÉ (SHK haut)
|
||||
→ DIAL_TONE : tonalité 440 Hz continu (FR) en boucle, attend le 1er chiffre
|
||||
→ DIALING : collecte les chiffres (coupe la tonalité au 1er), fin = numéro complet (match annuaire)
|
||||
OU timeout inter-chiffre ~3 s
|
||||
→ ROUTING : recherche du numéro dans l'annuaire
|
||||
├─ numéro connu → RINGBACK (440 Hz 1,5 s/3,5 s) → CONNECT
|
||||
│ → GREET (kind=greeting) → [ LISTEN(VAD) → /v1/voice/turn(kind=reply) → SPEAK ]*
|
||||
└─ numéro inconnu → tonalité « non attribué »/occupé (440 Hz 0,5/0,5) ~5 s → IDLE
|
||||
→ RACCROCHÉ (SHK bas) à tout instant → IDLE + mute (abort tour en cours)
|
||||
```
|
||||
|
||||
### Tonalités France Télécom (générées localement, pas de fichier)
|
||||
- **Tonalité (invitation à numéroter)** : 440 Hz continu.
|
||||
- **Occupé / non attribué** : 440 Hz, 0,5 s on / 0,5 s off.
|
||||
- **Retour d'appel (ringback)** : 440 Hz, 1,5 s on / 3,5 s off.
|
||||
- **Handshake Minitel** (pour 3615) : sample audio embarqué (le « bip strident » de connexion).
|
||||
|
||||
La tonalité/ringback passe par l'ampli PA → **forcer le PA on dès le décroché** (cohérent avec le mute-on-hook : décroché ⇒ PA on).
|
||||
|
||||
## 4. Numérotation — module `dialer`
|
||||
|
||||
Abstraction qui émet des événements `digit(0–9)` + `dial_complete`, **3 sources** combinables (Kconfig) :
|
||||
|
||||
- **Impulsions (cadran)** — `CONFIG_PLIP_DIAL_PULSE` : comptage d'impulsions sur le **pin SHK** du SLIC (la boucle s'ouvre/ferme pendant la numérotation au cadran ; 1 impulsion=1 … 10=0), gap inter-chiffre. **Bench-testable** avec un téléphone à cadran vintage branché sur le SLIC.
|
||||
- **DTMF (touches)** — `CONFIG_PLIP_DIAL_DTMF` : décodage **Goertzel** des 8 fréquences DTMF sur l'audio capté via le SLIC. Bench-testable (dépend du chemin micro).
|
||||
- **Debug/banc** — toujours dispo : `POST /debug/dial?number=NNNN` (+ option bouton BOOT = 1 chiffre) pour CI/validation sans téléphone physique.
|
||||
|
||||
Le SLIC classe **AG1171** fournit : hook + impulsions sur **SHK** (le hook GPIO actuel = SHK), audio combiné via le codec ES8388, sonnerie via les pins `RM`/`F-R`. Le mute-on-hook existant mappe exactement (SHK bas = raccroché = muet).
|
||||
|
||||
## 5. Annuaire (`game/scenarios/phone_directory.yaml`)
|
||||
|
||||
Table côté gateway : `numéro → { persona, voice, minitel? }`. Numéros supportés (réf. France Télécom) :
|
||||
|
||||
| Numéro | Service | Comportement |
|
||||
|---|---|---|
|
||||
| **12** | Renseignements (l'annuaire) | opératrice/standard : oriente, indice « quel numéro appeler » |
|
||||
| **3615** | Minitel (kiosque Télétel) | **handshake modem Minitel** en intro, puis agent vocal spécial « service Minitel » (persona datée/robotique), même boucle `/voice/turn` |
|
||||
| **15** | SAMU | PNJ secours médical en personnage |
|
||||
| **17** | Police secours | PNJ police |
|
||||
| **18** | Sapeurs-pompiers | PNJ pompiers |
|
||||
|
||||
Le numéro de **Professeur Zacus** (persona principale) est une entrée d'annuaire supplémentaire, numéro configurable. Le contenu des personas (caractère, ton, voix, anti-triche) est rempli par l'auteur après implémentation ; l'annuaire est extensible (autres PNJ = ajout d'entrées, sans reflasher). Numéro absent de l'annuaire → tonalité « non attribué ».
|
||||
|
||||
## 6. Contrat `/v1/voice/turn`
|
||||
|
||||
`POST /v1/voice/turn` (sous token) :
|
||||
|
||||
| Entrée | Type | Rôle |
|
||||
|---|---|---|
|
||||
| `session_id` | string | généré par le PLIP au décroché ; clé de l'historique |
|
||||
| `number` | string | le numéro composé → choix persona/voix dans l'annuaire |
|
||||
| `kind` | `"greeting"` \| `"reply"` | `greeting` = ouverture (pas d'audio) ; `reply` = tour normal |
|
||||
| `audio` (corps) | WAV 16k mono | la parole captée (absent si `kind=greeting`) |
|
||||
|
||||
**Sortie** : `200`, `Content-Type: audio/wav` = WAV 16k **prêt à jouer**. En-têtes debug : `X-Zacus-Heard` (transcription) + `X-Zacus-Said` (réponse texte).
|
||||
|
||||
`POST /v1/voice/end {session_id}` → purge l'historique (best-effort ; sinon TTL 10 min).
|
||||
|
||||
**Historique serveur** : `dict[session_id] → messages[]` (system persona du numéro + alternance user/assistant), borné aux ~8 derniers tours, purgé sur `/v1/voice/end` ou TTL.
|
||||
|
||||
### Flux d'un appel
|
||||
```
|
||||
1. numéro routé → POST /turn {session_id, number, kind:greeting}
|
||||
→ serveur : chat(persona accueil) → TTS → WAV → PLIP joue l'accueil
|
||||
2. PLIP capture le micro (VAD : démarre à la voix, stop au silence ~800 ms, cap ~8 s)
|
||||
3. POST /turn {session_id, number, kind:reply, audio:WAV}
|
||||
→ serveur : whisper(audio)→texte ; append historique ; chat(persona+historique)→réponse ; TTS→resample 16k → WAV
|
||||
4. PLIP joue la réponse → retour étape 2
|
||||
5. raccroché → abort + mute ; POST /v1/voice/end (best-effort)
|
||||
```
|
||||
|
||||
## 7. Services IA (état constaté 2026-06-14)
|
||||
|
||||
- **LLM (NPC brain)** : `gateway.ailiance.fr /v1/chat/completions` ✅ (Ministral & autres modèles ailiance).
|
||||
- **TTS** : `gateway.ailiance.fr /v1/audio/speech` ✅ (model `tts-1`, voix `nova`/`alloy` ; sortie WAV 22050 Hz → resample 16k côté gateway, réutilise `_wav_to_16k_mono`).
|
||||
- **STT** : **absent d'ailiance** (`/v1/audio/transcriptions` → 404). À fournir par whisper.cpp `:8300` ou la voice-bridge `/voice/transcribe` (studio:8200, **actuellement down**).
|
||||
|
||||
## 8. Dépendances bloquantes (honnêtes)
|
||||
|
||||
1. **Micro = vrai audio** : la capture renvoie aujourd'hui des zéros. Hypothèse forte : le SLIC ne connecte le chemin audio du combiné que **décroché**, et/ou l'entrée ES8388 utilisée n'est pas celle câblée à la sortie audio du SLIC. **Vérif à coût quasi nul** : téléphone branché + décroché → `/voice/capture` → RMS ≠ 0. Sinon, basculer l'entrée ES8388 (LIN2/RIN2 vs LIN1/RIN1) selon le câblage SLIC.
|
||||
2. **Endpoint whisper vivant** : relancer la voice-bridge sur studio OU whisper.cpp `:8300` OU exposer un endpoint STT sur la gateway.
|
||||
|
||||
## 9. Mode dégradé (sans micro / sans STT)
|
||||
|
||||
Le téléphone marche **en sens unique sans ces deux dépendances** : tonalité, numérotation, routage annuaire, ringback, **et chaque PNJ débite sa réplique d'accueil (TTS)**. La boucle d'écoute (LISTEN→STT) est sautée. → **un téléphone-annuaire France Télécom jouable est livrable AVANT le micro/whisper.**
|
||||
|
||||
## 10. Gestion d'erreur
|
||||
|
||||
- **STT vide/échec** → la gateway renvoie un WAV de relance (« Allô ? je vous entends mal, répétez ? », par persona) ; la boucle continue.
|
||||
- **Erreur réseau/ailiance** → WAV de repli (« la ligne est mauvaise… »).
|
||||
- **Raccroché en plein tour** → le PLIP abort le HTTP en cours (best-effort), stoppe l'audio, mute, IDLE.
|
||||
- **Numéro inconnu** → tonalité « non attribué » → IDLE.
|
||||
- **Taille réponse** capée ~7 s (240 KiB), cohérent avec `/v1/voice/say` et le `MAX_BODY` 256K du PLIP (upload/playback streaming).
|
||||
|
||||
## 11. Composants & fichiers
|
||||
|
||||
**Firmware PLIP** (`ESP32_ZACUS/plip_voice/main/`) :
|
||||
- `dialer.c/.h` (neuf) : impulsions SHK + DTMF Goertzel + `/debug/dial` → événements digit/dial_complete.
|
||||
- `tones.c/.h` (neuf) : tonalités FR + sample handshake Minitel.
|
||||
- `conversation.c/.h` (neuf) : machine à états complète, `session_id`, capture→POST `/v1/voice/turn`→play, abort au raccroché.
|
||||
- extension `phone.c` : les événements hook alimentent `conversation` ; SHK pulse → `dialer`.
|
||||
- client HTTP : POST du WAV capté à la gateway, réception du WAV de réponse.
|
||||
- réutilise : `/voice/capture` (mic+VAD), play streaming, mute-on-hook, ring.
|
||||
|
||||
**Gateway** (`tools/zacus-gateway/`) :
|
||||
- `POST /v1/voice/turn`, `POST /v1/voice/end`.
|
||||
- chargement `game/scenarios/phone_directory.yaml` (annuaire → persona/voix).
|
||||
- store de sessions en mémoire (TTL).
|
||||
- réutilise `_synthesise_wav(ailiance)` + `_wav_to_16k_mono`.
|
||||
|
||||
## 12. Étapes de build (incrémental, chaque étape livrable)
|
||||
|
||||
1. **Tonalités + dialer + machine à états** jusqu'à RINGBACK — testable au son, **zéro IA**.
|
||||
2. **Gateway `/v1/voice/turn` voie greeting + annuaire** → composer un numéro = **entendre le PNJ parler** (sens unique, sans micro/whisper). **Livrable jouable.**
|
||||
3. **Vérif micro (SLIC décroché) + whisper** → **conversation deux sens**.
|
||||
4. **3615 Minitel** (handshake + agent robotique).
|
||||
|
||||
## 13. Tests
|
||||
|
||||
- **Gateway** (unit) : `/v1/voice/turn` avec WAV fixture → routage persona par numéro, accumulation historique, voie greeting, fallbacks d'erreur (ailiance mocké ou live) ; chargement annuaire.
|
||||
- **Firmware** (banc, téléphone à cadran via SLIC) : décroché→tonalité ; composer 17→ringback→accueil police ; parler→réponse (gated micro) ; 3615→handshake Minitel→agent robotique ; numéro inconnu→occupé ; raccroché→silence. Impulsions via SHK ; DTMF Goertzel ; `/debug/dial` pour CI.
|
||||
- **Dégradé** : STT down → accueil + fallback fonctionnent.
|
||||
@@ -0,0 +1 @@
|
||||
VITE_GATEWAY_BASE_URL=https://zacus.saillant.cc/api
|
||||
@@ -1,8 +1,10 @@
|
||||
# Atelier
|
||||
|
||||
Scratch-like authoring studio: Blockly editor (left/center) + R3F 3D simulation stage (right) + validation console (bottom). Live-diff: edit a block → 500 ms debounce → stale badge → click Run → simulation reloads via `simStore.loadScenario(yaml)`.
|
||||
Scratch-like authoring studio: Blockly editor (left/center) + **Workbench pane** (right) + validation console (bottom). Live-diff: edit a block → 500 ms debounce → stale badge → click Run → `runtimeStore.commitPendingIr()`.
|
||||
|
||||
Design doc: `docs/superpowers/specs/2026-05-01-v3-fusion-atelier-design.md`.
|
||||
**Refonte UI « Workbench » en cours** (spec `docs/superpowers/specs/2026-06-13-atelier-refonte-workbench-design.md`, plan `docs/superpowers/plans/2026-06-13-atelier-refonte-workbench.md`). La simulation 3D (R3F/three, `StagePane`, `scene/`, `puzzles/`, `modes/`, `simStore`) a été **retirée** (phase A). La colonne droite est un placeholder « Banc d'essai » jusqu'à la phase C (= aperçu écran device fidèle + vue flux du scénario, split vertical). Phase B = thème néo-rétro Workbench (palette firmware, Orbitron/IBM Plex Mono).
|
||||
|
||||
Design doc historique : `docs/superpowers/specs/2026-05-01-v3-fusion-atelier-design.md`.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -13,20 +15,17 @@ src/
|
||||
main.css # Shell + pane styles
|
||||
vite-env.d.ts # Vite client types
|
||||
components/
|
||||
Layout.tsx # PanelGroup tree, ⌘B stage toggle
|
||||
Layout.tsx # PanelGroup tree, ⌘B workbench toggle
|
||||
EditorPane.tsx # Lazy(BlocklyWorkspace) + register blocks + validate on change
|
||||
StagePane.tsx # Lazy(RoomScene + mode HUDs) + Run button
|
||||
ConsolePane.tsx # Mode tabs + validation entries list
|
||||
WorkbenchPane.tsx # (phase C) split vertical: DeviceScreen + FlowGraph — placeholder for now
|
||||
ConsolePane.tsx # validation entries list (+ error/warn count)
|
||||
BlocklyWorkspace.tsx # forwardRef wrapper around Blockly.inject
|
||||
toolbox.ts # XML toolbox config
|
||||
YamlPreview.tsx # Syntax-highlighted YAML rendering
|
||||
{TextGen,AudioGen,ScreenEditor}Panel.tsx, AssetsPanel.tsx # ✦ Assets workspace (P2/P3a/P5)
|
||||
blocks/
|
||||
{puzzle,npc,flow}-blocks.ts # Block definitions + YAML generators
|
||||
index.ts # registerAllBlocks (idempotent)
|
||||
scene/ # R3F 3D scene (RoomScene, RtcPhone, PuzzleStations, NpcBubble)
|
||||
puzzles/ # P1Sound, P5Morse, P6Symbols, P7Coffre (3D widgets)
|
||||
modes/ # SandboxMode, DemoMode, TestMode HUDs
|
||||
stores/ # editor / runtime / sim / validation (zustand)
|
||||
index.ts # registerAllBlocks (idempotent) — block defs live in @zacus/shared/blockly
|
||||
stores/ # editor / runtime / validation (zustand) — NO simStore
|
||||
lib/
|
||||
yaml-export.ts # workspace -> YAML
|
||||
yaml-import.ts # YAML -> workspace
|
||||
@@ -36,12 +35,11 @@ src/
|
||||
|
||||
## Patterns
|
||||
|
||||
- **Lazy chunks** via `React.lazy` + Suspense for both Blockly (~700 kB gzip) and R3F+three (~1 MB gzip). Initial shell stays under 80 kB. Vite `manualChunks` in `vite.config.ts` enforces the split.
|
||||
- **Stores stay focused**: `editorStore` = blocklyJson string ; `runtimeStore` = pending/current IR + isStale ; `simStore` = mode + engine instance + npcMood ; `validationStore` = entries with `severity` + `source` (schema|compile|runtime|test).
|
||||
- **Mode is sim concern**, not editor↔stage sync — lives in `simStore`. ConsolePane reads from there.
|
||||
- **Run button** in `StagePane` is the only commit point: it calls `simStore.loadScenario(pendingIr)` then `runtimeStore.commitPendingIr()`.
|
||||
- **Tailwind classes are not configured** — use inline styles or `main.css` classes (`atelier-pane`, `atelier-mode-tab`, `atelier-resizer--*`). Tailwind in legacy simulation HUDs was rewritten as inline styles during P4.
|
||||
- **Dev-only window hooks**: `App.tsx` exposes stores on `window.__atelierStores`, `Layout.tsx` exposes `window.__atelierToggleStage`, `EditorPane.tsx` exposes `window.__atelierBlockly.getWorkspace()`. All gated by `import.meta.env.DEV`. E2E specs drive workflow through them instead of relying on real DnD.
|
||||
- **Lazy chunk** via `React.lazy` + Suspense for Blockly (~700 kB gzip). Initial shell stays small. Vite `manualChunks` in `vite.config.ts` enforces the blockly split (the three/R3F chunk was removed with the 3D sim).
|
||||
- **Stores stay focused**: `editorStore` = blocklyJson string ; `runtimeStore` = pending/current IR + isStale ; `validationStore` = entries with `severity` + `source` (schema|compile|runtime|test). (phase C adds `selectionStore` = selectedStepId.)
|
||||
- **Live-diff commit**: edits flow through `useLiveDiff` (debounce → `setPendingIr`); committing pending→current IR is `runtimeStore.commitPendingIr()`.
|
||||
- **Tailwind classes are not configured** — use inline styles or `main.css` classes (`atelier-pane`, `atelier-resizer--*`). Phase B introduces a Workbench theme module `src/theme/workbench.ts` (palette/fonts) — import `WB` from there rather than hardcoding colours.
|
||||
- **Dev-only window hooks**: `App.tsx` exposes stores on `window.__atelierStores` (editor/runtime/validation), `Layout.tsx` exposes `window.__atelierToggleStage`, `EditorPane.tsx` exposes `window.__atelierBlockly.getWorkspace()`. All gated by `import.meta.env.DEV`. E2E specs drive workflow through them instead of relying on real DnD.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -54,8 +52,8 @@ pnpm exec vite build # for dev / desktop bundle (root path)
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Putting `mode` back into `runtimeStore` — it belongs to `simStore`.
|
||||
- Calling `voiceWsConnect()` or any lwip-touching code at boot without a network-ready guard (matches the ESP32_ZACUS bug fix from this session).
|
||||
- Reintroducing the 3D sim / `simStore` / R3F deps — the refonte removed them deliberately.
|
||||
- Hardcoding Workbench colours — import `WB` from `src/theme/workbench.ts` (phase B+).
|
||||
- Hardcoding `/atelier/` as base path — the dev server uses root, only the deployed prod build uses the prefix.
|
||||
- Adding deps to root `frontend-v3/package.json` — atelier deps live here.
|
||||
- Bypassing the Run button to mutate `simStore.engine` directly — breaks the live-diff contract.
|
||||
- Mutating `runtimeStore.currentIr` outside the live-diff contract (`setPendingIr` → `commitPendingIr`).
|
||||
|
||||
@@ -10,22 +10,23 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/orbitron": "^5.2.8",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
"@zacus/scenario-engine": "workspace:*",
|
||||
"@zacus/shared": "workspace:*",
|
||||
"@zacus/ui": "workspace:*",
|
||||
"blockly": "^12.5.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"three": "^0.171.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/three": "^0.184.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Layout } from './components/Layout.js';
|
||||
import { GameConfigPanel } from './components/GameConfigPanel.js';
|
||||
import { MenuBar } from './components/MenuBar.js';
|
||||
import { useLiveDiff } from './lib/useLiveDiff.js';
|
||||
import { useShareLink } from './lib/useShareLink.js';
|
||||
import { useEditorStore } from './stores/editorStore.js';
|
||||
import { useRuntimeStore } from './stores/runtimeStore.js';
|
||||
import { useSimStore } from './stores/simStore.js';
|
||||
import { useValidationStore } from './stores/validationStore.js';
|
||||
|
||||
// Dev-only test hooks — used by e2e/ specs to drive store state without
|
||||
@@ -13,27 +13,17 @@ if (import.meta.env.DEV) {
|
||||
(window as unknown as { __atelierStores?: unknown }).__atelierStores = {
|
||||
editor: useEditorStore,
|
||||
runtime: useRuntimeStore,
|
||||
sim: useSimStore,
|
||||
validation: useValidationStore,
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useLiveDiff();
|
||||
useShareLink();
|
||||
return (
|
||||
<>
|
||||
<div className="atelier-root">
|
||||
<MenuBar />
|
||||
<Layout />
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 12,
|
||||
right: 12,
|
||||
zIndex: 50,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<GameConfigPanel />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import * as Blockly from 'blockly/core';
|
||||
import { javascriptGenerator } from 'blockly/javascript';
|
||||
|
||||
// 2 flow block definitions: phase container and puzzle selector
|
||||
|
||||
export const FLOW_BLOCK_DEFS = [
|
||||
{
|
||||
type: 'phase_container',
|
||||
message0: 'PHASE %1',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'PHASE_TYPE',
|
||||
options: [
|
||||
['INTRO', 'INTRO'],
|
||||
['PROFILING', 'PROFILING'],
|
||||
['ADAPTIVE', 'ADAPTIVE'],
|
||||
['CLIMAX', 'CLIMAX'],
|
||||
['OUTRO', 'OUTRO'],
|
||||
],
|
||||
},
|
||||
],
|
||||
message1: '%1',
|
||||
args1: [{ type: 'input_statement', name: 'PUZZLES' }],
|
||||
colour: '#2ECC71',
|
||||
tooltip: 'Conteneur de phase du scénario',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_selector',
|
||||
message0: 'Puzzle %1 | profil %2',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'PUZZLE_REF',
|
||||
options: [
|
||||
['P1 Séquence Sonore', 'P1_SON'],
|
||||
['P2 Circuit LED', 'P2_CIRCUIT'],
|
||||
['P3 QR Treasure', 'P3_QR'],
|
||||
['P4 Radio', 'P4_RADIO'],
|
||||
['P5 Morse', 'P5_MORSE'],
|
||||
['P6 Symboles NFC', 'P6_SYMBOLES'],
|
||||
['P7 Coffre Final', 'P7_COFFRE'],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'PROFILE_FILTER',
|
||||
options: [
|
||||
['tous (BOTH)', 'BOTH'],
|
||||
['tech', 'TECH'],
|
||||
['non-tech', 'NON_TECH'],
|
||||
],
|
||||
},
|
||||
],
|
||||
colour: '#2ECC71',
|
||||
tooltip: 'Référence à un puzzle dans une phase',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function registerFlowBlocks(): void {
|
||||
for (const def of FLOW_BLOCK_DEFS) {
|
||||
Blockly.Blocks[def.type] = {
|
||||
init(this: Blockly.Block) {
|
||||
this.jsonInit(def);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function registerFlowGenerators(gen: any): void {
|
||||
gen['phase_container'] = (block: Blockly.Block) => {
|
||||
const phaseType = block.getFieldValue('PHASE_TYPE') as string;
|
||||
const inner = javascriptGenerator.statementToCode(block, 'PUZZLES');
|
||||
return `\n- type: ${phaseType}\n puzzles:${inner || '\n []'}`;
|
||||
};
|
||||
|
||||
gen['puzzle_selector'] = (block: Blockly.Block) => {
|
||||
const ref = block.getFieldValue('PUZZLE_REF') as string;
|
||||
const filter = block.getFieldValue('PROFILE_FILTER') as string;
|
||||
return `\n - puzzle_ref: ${ref}\n profile_filter: ${filter}`;
|
||||
};
|
||||
}
|
||||
@@ -1,30 +1,15 @@
|
||||
import { javascriptGenerator } from 'blockly/javascript';
|
||||
import { registerPuzzleBlocks, registerPuzzleGenerators } from './puzzle-blocks.js';
|
||||
import { registerNpcBlocks, registerNpcGenerators } from './npc-blocks.js';
|
||||
import { registerFlowBlocks, registerFlowGenerators } from './flow-blocks.js';
|
||||
import * as Blockly from 'blockly/core';
|
||||
import { registerZacusBlocks } from '@zacus/shared/blockly';
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Register all 12 Blockly block definitions and their YAML generators.
|
||||
* Safe to call multiple times — only registers once.
|
||||
* Register the shared Zacus block catalog (38 Runtime 3 scene blocks —
|
||||
* single source of truth in @zacus/shared/blockly, also consumed by the
|
||||
* zacus-hub native Studio). Safe to call multiple times.
|
||||
*/
|
||||
export function registerAllBlocks(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
// Register block shapes
|
||||
registerPuzzleBlocks();
|
||||
registerNpcBlocks();
|
||||
registerFlowBlocks();
|
||||
|
||||
// Register YAML generators (use javascriptGenerator as the vehicle)
|
||||
registerPuzzleGenerators(javascriptGenerator);
|
||||
registerNpcGenerators(javascriptGenerator);
|
||||
registerFlowGenerators(javascriptGenerator);
|
||||
registerZacusBlocks(Blockly);
|
||||
}
|
||||
|
||||
// Re-export individual registrars for targeted use
|
||||
export { registerPuzzleBlocks, registerPuzzleGenerators } from './puzzle-blocks.js';
|
||||
export { registerNpcBlocks, registerNpcGenerators } from './npc-blocks.js';
|
||||
export { registerFlowBlocks, registerFlowGenerators } from './flow-blocks.js';
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as Blockly from 'blockly/core';
|
||||
|
||||
// 3 NPC block definitions: profiling, duration, adaptive rule
|
||||
|
||||
export const NPC_BLOCK_DEFS = [
|
||||
{
|
||||
type: 'npc_profiling',
|
||||
message0: 'NPC Profiling | seuil tech %1 s | seuil non-tech %2 s',
|
||||
args0: [
|
||||
{ type: 'field_number', name: 'TECH_THRESHOLD_S', value: 300, min: 60, max: 600 },
|
||||
{ type: 'field_number', name: 'NONTECH_THRESHOLD_S', value: 480, min: 60, max: 900 },
|
||||
],
|
||||
colour: '#5B4FD6',
|
||||
tooltip: 'Configure la logique de profilage du groupe par le NPC',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'npc_duration',
|
||||
message0: 'NPC Durée | cible %1 min | mode %2',
|
||||
args0: [
|
||||
{ type: 'field_number', name: 'TARGET_MINUTES', value: 60, min: 30, max: 90 },
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'MODE',
|
||||
options: [
|
||||
['30 min', '30'],
|
||||
['45 min', '45'],
|
||||
['60 min', '60'],
|
||||
['90 min', '90'],
|
||||
],
|
||||
},
|
||||
],
|
||||
colour: '#5B4FD6',
|
||||
tooltip: 'Configure la durée cible de la session',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'npc_adaptive_rule',
|
||||
message0: 'NPC Règle | si %1 → %2',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'CONDITION',
|
||||
options: [
|
||||
['trop rapide', 'fast'],
|
||||
['trop lent', 'slow'],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'ACTION',
|
||||
options: [
|
||||
['ajouter puzzle', 'add'],
|
||||
['passer puzzle', 'skip'],
|
||||
['donner indice', 'hint'],
|
||||
],
|
||||
},
|
||||
],
|
||||
colour: '#5B4FD6',
|
||||
tooltip: 'Règle adaptative NPC (ajustement en cours de jeu)',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function registerNpcBlocks(): void {
|
||||
for (const def of NPC_BLOCK_DEFS) {
|
||||
Blockly.Blocks[def.type] = {
|
||||
init(this: Blockly.Block) {
|
||||
this.jsonInit(def);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function registerNpcGenerators(gen: any): void {
|
||||
gen['npc_profiling'] = (block: Blockly.Block) => {
|
||||
const tech = block.getFieldValue('TECH_THRESHOLD_S') as string;
|
||||
const nontech = block.getFieldValue('NONTECH_THRESHOLD_S') as string;
|
||||
return `\n profiling:\n tech_threshold_s: ${tech}\n nontech_threshold_s: ${nontech}`;
|
||||
};
|
||||
|
||||
gen['npc_duration'] = (block: Blockly.Block) => {
|
||||
const target = block.getFieldValue('TARGET_MINUTES') as string;
|
||||
const mode = block.getFieldValue('MODE') as string;
|
||||
return `\n duration:\n target_minutes: ${target}\n mode: "${mode}"`;
|
||||
};
|
||||
|
||||
gen['npc_adaptive_rule'] = (block: Blockly.Block) => {
|
||||
const cond = block.getFieldValue('CONDITION') as string;
|
||||
const action = block.getFieldValue('ACTION') as string;
|
||||
return `\n - condition: ${cond}\n action: ${action}`;
|
||||
};
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import * as Blockly from 'blockly/core';
|
||||
|
||||
// 7 puzzle block definitions with JSON defs.
|
||||
// YAML generators are in ../yaml/generators.ts
|
||||
|
||||
export const PUZZLE_BLOCK_DEFS = [
|
||||
{
|
||||
type: 'puzzle_sequence_sonore',
|
||||
message0: 'P1 Séquence Sonore | notes %1 | difficulté %2 | code digits %3',
|
||||
args0: [
|
||||
{ type: 'field_input', name: 'MELODY_NOTES', text: 'do,re,mi,fa' },
|
||||
{ type: 'field_number', name: 'DIFFICULTY', value: 1, min: 1, max: 5 },
|
||||
{ type: 'field_input', name: 'CODE_DIGITS', text: '1,2' },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P1: Séquence Sonore — joueur reproduit une mélodie',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_circuit_led',
|
||||
message0: 'P2 Circuit LED | composants %1 | circuit valide %2 | code digit %3',
|
||||
args0: [
|
||||
{ type: 'field_input', name: 'COMPONENTS', text: 'resistor,led,capacitor' },
|
||||
{ type: 'field_input', name: 'VALID_CIRCUIT', text: 'series_parallel' },
|
||||
{ type: 'field_number', name: 'CODE_DIGIT', value: 3, min: 0, max: 9 },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P2: Circuit LED — joueur câble un circuit électronique',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_qr_treasure',
|
||||
message0: 'P3 QR Treasure | QR codes %1 | ordre correct %2 | code digit %3',
|
||||
args0: [
|
||||
{ type: 'field_input', name: 'QR_CODES', text: 'qr1,qr2,qr3,qr4,qr5,qr6' },
|
||||
{ type: 'field_input', name: 'CORRECT_ORDER', text: '3,1,4,1,5,9' },
|
||||
{ type: 'field_number', name: 'CODE_DIGIT', value: 5, min: 0, max: 9 },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P3: QR Treasure — scanner 6 QR codes dans le bon ordre',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_radio',
|
||||
message0: 'P4 Radio | fréquence cible %1 | min %2 | max %3 | code digit %4',
|
||||
args0: [
|
||||
{ type: 'field_number', name: 'TARGET_FREQ', value: 93.5, min: 88, max: 108 },
|
||||
{ type: 'field_number', name: 'RANGE_MIN', value: 93.0 },
|
||||
{ type: 'field_number', name: 'RANGE_MAX', value: 94.0 },
|
||||
{ type: 'field_number', name: 'CODE_DIGIT', value: 7, min: 0, max: 9 },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P4: Fréquence Radio — syntoniser une fréquence précise',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_morse',
|
||||
message0: 'P5 Morse | message %1 | mode %2 | code digits %3',
|
||||
args0: [
|
||||
{ type: 'field_input', name: 'MESSAGE', text: 'SOS' },
|
||||
{
|
||||
type: 'field_dropdown',
|
||||
name: 'MODE',
|
||||
options: [
|
||||
['tech (clé télégraphe)', 'tech'],
|
||||
['light (lampe)', 'light'],
|
||||
],
|
||||
},
|
||||
{ type: 'field_input', name: 'CODE_DIGITS', text: '4,2' },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P5: Code Morse — transmettre un message en morse',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_symboles_nfc',
|
||||
message0: 'P6 Symboles NFC | symboles %1 | ordre correct %2 | code digits %3',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_input',
|
||||
name: 'SYMBOLS',
|
||||
text: 'alpha,beta,gamma,delta,sigma,omega,phi,psi,chi,rho,mu,nu',
|
||||
},
|
||||
{ type: 'field_input', name: 'CORRECT_ORDER', text: '1,4,7,2' },
|
||||
{ type: 'field_input', name: 'CODE_DIGITS', text: '6,1' },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P6: Symboles Alchimiques NFC — placer des tuiles dans le bon ordre',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
{
|
||||
type: 'puzzle_coffre_final',
|
||||
message0: 'P7 Coffre Final | longueur code %1 | max tentatives %2',
|
||||
args0: [
|
||||
{ type: 'field_number', name: 'CODE_LENGTH', value: 8, min: 4, max: 12 },
|
||||
{ type: 'field_number', name: 'MAX_ATTEMPTS', value: 3, min: 1, max: 10 },
|
||||
],
|
||||
colour: '#FF6B35',
|
||||
tooltip: 'P7: Coffre Final — entrer le code assemblé depuis tous les puzzles',
|
||||
previousStatement: null,
|
||||
nextStatement: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function registerPuzzleBlocks(): void {
|
||||
for (const def of PUZZLE_BLOCK_DEFS) {
|
||||
Blockly.Blocks[def.type] = {
|
||||
init(this: Blockly.Block) {
|
||||
this.jsonInit(def);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// YAML generators for puzzle blocks
|
||||
export function registerPuzzleGenerators(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
gen: any,
|
||||
): void {
|
||||
gen['puzzle_sequence_sonore'] = (block: Blockly.Block) => {
|
||||
const notes = block.getFieldValue('MELODY_NOTES') as string;
|
||||
const diff = block.getFieldValue('DIFFICULTY') as string;
|
||||
const digits = block.getFieldValue('CODE_DIGITS') as string;
|
||||
return `\n - id: P1_SON\n name: "Séquence Sonore"\n melody_notes: [${notes
|
||||
.split(',')
|
||||
.map((n) => `"${n.trim()}"`)
|
||||
.join(', ')}]\n difficulty: ${diff}\n code_digits: [${digits}]`;
|
||||
};
|
||||
|
||||
gen['puzzle_circuit_led'] = (block: Blockly.Block) => {
|
||||
const components = block.getFieldValue('COMPONENTS') as string;
|
||||
const circuit = block.getFieldValue('VALID_CIRCUIT') as string;
|
||||
const digit = block.getFieldValue('CODE_DIGIT') as string;
|
||||
return `\n - id: P2_CIRCUIT\n name: "Circuit LED"\n components: [${components
|
||||
.split(',')
|
||||
.map((c) => `"${c.trim()}"`)
|
||||
.join(', ')}]\n valid_circuit: "${circuit}"\n code_digit: ${digit}`;
|
||||
};
|
||||
|
||||
gen['puzzle_qr_treasure'] = (block: Blockly.Block) => {
|
||||
const qrs = block.getFieldValue('QR_CODES') as string;
|
||||
const order = block.getFieldValue('CORRECT_ORDER') as string;
|
||||
const digit = block.getFieldValue('CODE_DIGIT') as string;
|
||||
return `\n - id: P3_QR\n name: "QR Treasure"\n qr_codes: [${qrs
|
||||
.split(',')
|
||||
.map((q) => `"${q.trim()}"`)
|
||||
.join(', ')}]\n correct_order: [${order}]\n code_digit: ${digit}`;
|
||||
};
|
||||
|
||||
gen['puzzle_radio'] = (block: Blockly.Block) => {
|
||||
const freq = block.getFieldValue('TARGET_FREQ') as string;
|
||||
const min = block.getFieldValue('RANGE_MIN') as string;
|
||||
const max = block.getFieldValue('RANGE_MAX') as string;
|
||||
const digit = block.getFieldValue('CODE_DIGIT') as string;
|
||||
return `\n - id: P4_RADIO\n name: "Fréquence Radio"\n target_freq: ${freq}\n range_min: ${min}\n range_max: ${max}\n code_digit: ${digit}`;
|
||||
};
|
||||
|
||||
gen['puzzle_morse'] = (block: Blockly.Block) => {
|
||||
const msg = block.getFieldValue('MESSAGE') as string;
|
||||
const mode = block.getFieldValue('MODE') as string;
|
||||
const digits = block.getFieldValue('CODE_DIGITS') as string;
|
||||
return `\n - id: P5_MORSE\n name: "Code Morse"\n message: "${msg}"\n mode: ${mode}\n code_digits: [${digits}]`;
|
||||
};
|
||||
|
||||
gen['puzzle_symboles_nfc'] = (block: Blockly.Block) => {
|
||||
const symbols = block.getFieldValue('SYMBOLS') as string;
|
||||
const order = block.getFieldValue('CORRECT_ORDER') as string;
|
||||
const digits = block.getFieldValue('CODE_DIGITS') as string;
|
||||
return `\n - id: P6_SYMBOLES\n name: "Symboles Alchimiques"\n symbols: [${symbols
|
||||
.split(',')
|
||||
.map((s) => `"${s.trim()}"`)
|
||||
.join(', ')}]\n correct_order: [${order}]\n code_digits: [${digits}]`;
|
||||
};
|
||||
|
||||
gen['puzzle_coffre_final'] = (block: Blockly.Block) => {
|
||||
const len = block.getFieldValue('CODE_LENGTH') as string;
|
||||
const attempts = block.getFieldValue('MAX_ATTEMPTS') as string;
|
||||
return `\n - id: P7_COFFRE\n name: "Coffre Final"\n code_length: ${len}\n max_attempts: ${attempts}`;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TextGenBody } from './TextGenPanel.js';
|
||||
import { AudioGenBody } from './AudioGenPanel.js';
|
||||
import { ScreenEditorBody } from './ScreenEditorPanel.js';
|
||||
import {
|
||||
fetchBoards,
|
||||
stageAssets,
|
||||
armStep,
|
||||
playMedia,
|
||||
type StageBoard,
|
||||
type StageResult,
|
||||
type ArmStepResult,
|
||||
type PlayMediaResult,
|
||||
} from '../lib/stage.js';
|
||||
|
||||
/**
|
||||
* P5 — unified "Assets" workspace. Folds the per-type authoring bodies (text /
|
||||
* voice / screen) into one tabbed panel and adds a Deploy tab that stages the
|
||||
* generated audio onto a board's SD via the gateway (POST /v1/assets/stage,
|
||||
* P4). Replaces the three separate floating panels.
|
||||
*/
|
||||
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
|
||||
|
||||
type Tab = 'text' | 'voice' | 'screen' | 'deploy';
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'text', label: 'Texte' },
|
||||
{ id: 'voice', label: 'Voix' },
|
||||
{ id: 'screen', label: 'Écran' },
|
||||
{ id: 'deploy', label: 'Déployer' },
|
||||
];
|
||||
|
||||
function DeployTab() {
|
||||
const [boards, setBoards] = useState<StageBoard[]>([]);
|
||||
const [board, setBoard] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<StageResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// « Tester sur la carte » — arm a step (D2) / play a media cue (D3) on the selected board.
|
||||
const [stepId, setStepId] = useState('STEP_intro');
|
||||
const [mediaPath, setMediaPath] = useState('');
|
||||
const [testBusy, setTestBusy] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ArmStepResult | PlayMediaResult | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchBoards()
|
||||
.then((b) => { if (alive) { setBoards(b); setBoard((cur) => cur || b[0]?.name || ''); } })
|
||||
.catch((e) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const onStage = async () => {
|
||||
if (!board) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await stageAssets(board, { strategy: 'auto' }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async (fn: () => Promise<ArmStepResult | PlayMediaResult>) => {
|
||||
if (!board) return;
|
||||
setTestBusy(true);
|
||||
setTestError(null);
|
||||
setTestResult(null);
|
||||
try {
|
||||
setTestResult(await fn());
|
||||
} catch (err) {
|
||||
setTestError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTestBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onArm = () => {
|
||||
const id = stepId.trim();
|
||||
if (!id) { setTestError('step_id requis'); return; }
|
||||
void runTest(() => armStep(board, id));
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
const p = mediaPath.trim();
|
||||
if (!p) { setTestError('chemin média requis'); return; }
|
||||
void runTest(() => playMedia(board, p));
|
||||
};
|
||||
|
||||
const dot = (s: string) => (s === 'ok' ? '#34d399' : s === 'error' ? '#fca5a5' : '#fbbf24');
|
||||
const inputStyle = {
|
||||
flex: 1, minWidth: 0, background: '#1f1f22', color: C.text,
|
||||
border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<span style={{ color: C.sub }}>
|
||||
Pousse les voix générées (onglet Voix) sur la SD d’une carte : <code>/sdcard/zacus/<carte>/</code>.
|
||||
Carte avec IP → push direct ; sans IP → bundle à copier sur la SD.
|
||||
</span>
|
||||
<select value={board} onChange={(e) => setBoard(e.target.value)}
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
|
||||
{boards.length === 0 && <option value="">(aucune carte — gateway ?)</option>}
|
||||
{boards.map((b) => <option key={b.name} value={b.name}>{b.label} {b.ip ? `(${b.ip})` : '(hors-ligne)'}</option>)}
|
||||
</select>
|
||||
|
||||
<button type="button" onClick={onStage} disabled={busy || !board}
|
||||
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: !board ? 0.4 : 1 }}>
|
||||
{busy ? 'Déploiement…' : 'Déployer les assets sur la carte'}
|
||||
</button>
|
||||
|
||||
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
|
||||
{result && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, background: '#1a1a1d',
|
||||
border: `1px solid ${C.border}`, borderRadius: 4, padding: 8 }}>
|
||||
<span style={{ color: result.ok ? '#34d399' : '#fca5a5' }}>
|
||||
{result.strategy_used.toUpperCase()} → {result.base_path} {result.ok ? '✓' : '✗'}
|
||||
</span>
|
||||
{result.steps.map((s, i) => (
|
||||
<span key={i} style={{ color: C.sub }}>
|
||||
<span style={{ color: dot(s.status) }}>●</span> {s.asset} — {s.detail}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: `1px solid ${C.border}`, marginTop: 4, paddingTop: 8,
|
||||
display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<strong style={{ color: C.text }}>Tester sur la carte</strong>
|
||||
<span style={{ color: C.sub }}>
|
||||
Arme une étape (D2) ou joue un cue média (D3) sur la carte sélectionnée, via la gateway.
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="text" value={stepId} onChange={(e) => setStepId(e.target.value)}
|
||||
placeholder="STEP_intro" aria-label="step_id" style={inputStyle} />
|
||||
<button type="button" onClick={onArm} disabled={testBusy || !board}
|
||||
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: testBusy ? C.bg : C.accent, color: '#fff',
|
||||
cursor: testBusy ? 'wait' : 'pointer', opacity: !board ? 0.4 : 1 }}>
|
||||
Armer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="text" value={mediaPath} onChange={(e) => setMediaPath(e.target.value)}
|
||||
placeholder="zacus/master/cue.wav" aria-label="chemin média" style={inputStyle} />
|
||||
<button type="button" onClick={onPlay} disabled={testBusy || !board}
|
||||
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: testBusy ? C.bg : C.accent, color: '#fff',
|
||||
cursor: testBusy ? 'wait' : 'pointer', opacity: !board ? 0.4 : 1 }}>
|
||||
Jouer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testError && <span style={{ color: '#fca5a5' }}>{testError}</span>}
|
||||
{testResult && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, background: '#1a1a1d',
|
||||
border: `1px solid ${C.border}`, borderRadius: 4, padding: 8 }}>
|
||||
<span style={{ color: testResult.ok ? '#34d399' : '#fca5a5' }}>
|
||||
<span style={{ color: dot(testResult.ok ? 'ok' : 'error') }}>●</span>{' '}
|
||||
{testResult.route} — HTTP {testResult.status} {testResult.ok ? '✓' : '✗'}
|
||||
{'armed' in testResult ? ` · armé : ${testResult.armed ? 'oui' : 'non'}` : ''}
|
||||
</span>
|
||||
{testResult.detail && <span style={{ color: C.sub }}>{testResult.detail}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabbed Assets body (text / voice / screen / deploy). Rendered inside the
|
||||
* menu-bar popover — the open/close state lives in `MenuBar`, so this no longer
|
||||
* carries its own floating trigger button or window chrome.
|
||||
*/
|
||||
export function AssetsBody() {
|
||||
const [tab, setTab] = useState<Tab>('text');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="assets-panel"
|
||||
style={{
|
||||
width: 340, padding: 12, borderRadius: 8, background: C.bg,
|
||||
border: `1px solid ${C.accent}`,
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
fontSize: 12, color: C.text,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: '#c084fc' }}>✦ Assets</strong>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} type="button" onClick={() => setTab(t.id)}
|
||||
style={{ flex: 1, padding: '4px 0', border: `1px solid ${tab === t.id ? C.accent : C.border}`,
|
||||
borderRadius: 5, background: tab === t.id ? C.accent : '#1f1f22',
|
||||
color: tab === t.id ? '#fff' : C.sub, cursor: 'pointer', fontSize: 11 }}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'text' && <TextGenBody />}
|
||||
{tab === 'voice' && <AudioGenBody />}
|
||||
{tab === 'screen' && <ScreenEditorBody />}
|
||||
{tab === 'deploy' && <DeployTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { generateAudio, stagedUrl, type AudioBackend, type AudioGenRequest } from '../lib/audiogen.js';
|
||||
|
||||
/**
|
||||
* Audio (TTS) generation body (P2, hosted in the unified Assets panel).
|
||||
* Synthesises a line either in the cloned Professor Zacus voice (f5, via the
|
||||
* voice-bridge) or in a stock voice (kokoro, via vllm-mlx), polls the async
|
||||
* job, then previews the staged WAV in an <audio> player so the author can
|
||||
* audition before staging it onto a board (Deploy tab).
|
||||
*/
|
||||
const BACKENDS: { value: AudioBackend; label: string; hint: string }[] = [
|
||||
{ value: 'f5', label: 'Voix Zacus (clonée)', hint: 'Voix du professeur, via voice-bridge F5' },
|
||||
{ value: 'kokoro', label: 'Voix stock (vllm-mlx)', hint: 'Voix génériques FR/EN, Kokoro' },
|
||||
];
|
||||
|
||||
const KOKORO_VOICES: { value: string; label: string }[] = [
|
||||
{ value: 'ff_siwis', label: 'FR — Siwis (♀)' },
|
||||
{ value: 'af_heart', label: 'EN — Heart (♀)' },
|
||||
{ value: 'af_bella', label: 'EN — Bella (♀)' },
|
||||
{ value: 'am_adam', label: 'EN — Adam (♂)' },
|
||||
];
|
||||
|
||||
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
|
||||
|
||||
export function AudioGenBody() {
|
||||
const [backend, setBackend] = useState<AudioBackend>('f5');
|
||||
const [voice, setVoice] = useState('ff_siwis');
|
||||
const [text, setText] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!text.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setPreviewUrl(null);
|
||||
try {
|
||||
const req: AudioGenRequest = { text: text.trim(), backend };
|
||||
if (backend === 'kokoro') req.voice = voice;
|
||||
if (key.trim()) req.key = key.trim();
|
||||
const job = await generateAudio(req);
|
||||
if (job.staged_key) setPreviewUrl(`${stagedUrl(job.staged_key)}?t=${Date.now()}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hint = BACKENDS.find((b) => b.value === backend)?.hint ?? '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<select value={backend} onChange={(e) => setBackend(e.target.value as AudioBackend)}
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
|
||||
{BACKENDS.map((b) => <option key={b.value} value={b.value}>{b.label}</option>)}
|
||||
</select>
|
||||
<span style={{ color: C.sub, marginTop: -4 }}>{hint}</span>
|
||||
|
||||
{backend === 'kokoro' && (
|
||||
<select value={voice} onChange={(e) => setVoice(e.target.value)}
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
|
||||
{KOKORO_VOICES.map((v) => <option key={v.value} value={v.value}>{v.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3}
|
||||
placeholder="Texte à dire (ex. « Je suis le professeur Zacus. »)"
|
||||
style={{ resize: 'vertical', background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`,
|
||||
borderRadius: 4, padding: 6, fontFamily: 'inherit', fontSize: 12 }} />
|
||||
|
||||
<input value={key} onChange={(e) => setKey(e.target.value)} placeholder="clé/id (optionnel, ex. zacus_intro)"
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }} />
|
||||
|
||||
<button type="button" onClick={onGenerate} disabled={busy || !text.trim()}
|
||||
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: !text.trim() ? 0.4 : 1 }}>
|
||||
{busy ? 'Génération…' : 'Générer'}
|
||||
</button>
|
||||
|
||||
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
|
||||
{previewUrl && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio controls src={previewUrl} style={{ width: '100%' }} />
|
||||
<a href={previewUrl} download
|
||||
style={{ color: '#34d399', fontSize: 11, alignSelf: 'flex-start', textDecoration: 'none' }}>
|
||||
↓ Télécharger le WAV
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,30 @@
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import * as Blockly from 'blockly';
|
||||
import { TOOLBOX_XML } from './toolbox.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
/**
|
||||
* Workbench Blockly theme — defined once at module scope (defineTheme at the
|
||||
* top level of a module has no side effects beyond registering the theme, so it
|
||||
* is safe and avoids re-defining under StrictMode's double-invoked effects).
|
||||
* All colours route through `WB` (single source of truth — see CLAUDE.md).
|
||||
*/
|
||||
const workbenchTheme = Blockly.Theme.defineTheme('zacus-workbench', {
|
||||
name: 'zacus-workbench',
|
||||
base: Blockly.Themes.Classic,
|
||||
componentStyles: {
|
||||
workspaceBackgroundColour: WB.bg,
|
||||
toolboxBackgroundColour: WB.panel,
|
||||
toolboxForegroundColour: WB.text,
|
||||
flyoutBackgroundColour: WB.panel,
|
||||
flyoutForegroundColour: WB.label,
|
||||
scrollbarColour: WB.panelBorder,
|
||||
insertionMarkerColour: WB.orange,
|
||||
// Grid colour is set via the inject `grid.colour` option below —
|
||||
// `gridColour` is not a Blockly v12 ComponentStyle key.
|
||||
},
|
||||
fontStyle: { family: WB.fontMono, size: 11 },
|
||||
});
|
||||
|
||||
export interface BlocklyWorkspaceHandle {
|
||||
getWorkspace: () => Blockly.WorkspaceSvg | null;
|
||||
@@ -29,9 +53,9 @@ export const BlocklyWorkspace = forwardRef<BlocklyWorkspaceHandle, BlocklyWorksp
|
||||
|
||||
const ws = Blockly.inject(containerRef.current, {
|
||||
toolbox: TOOLBOX_XML,
|
||||
grid: { spacing: 20, length: 3, colour: '#2c2c2e', snap: true },
|
||||
grid: { spacing: 24, length: 3, colour: WB.gridSubtle, snap: true },
|
||||
zoom: { controls: true, wheel: true, startScale: 0.9 },
|
||||
theme: Blockly.Themes.Classic,
|
||||
theme: workbenchTheme,
|
||||
renderer: 'zelos',
|
||||
trashcan: true,
|
||||
move: { scrollbars: true, drag: true, wheel: false },
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useSimStore, type SimMode } from '../stores/simStore.js';
|
||||
import { useValidationStore, type ValidationEntry } from '../stores/validationStore.js';
|
||||
|
||||
const MODES: SimMode[] = ['sandbox', 'demo', 'test'];
|
||||
|
||||
const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
|
||||
error: '#fca5a5',
|
||||
warning: '#fcd34d',
|
||||
@@ -10,8 +7,6 @@ const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
|
||||
};
|
||||
|
||||
export function ConsolePane() {
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
const setMode = useSimStore((s) => s.setMode);
|
||||
const entries = useValidationStore((s) => s.entries);
|
||||
|
||||
const errorCount = entries.filter((e) => e.severity === 'error').length;
|
||||
@@ -20,20 +15,6 @@ export function ConsolePane() {
|
||||
return (
|
||||
<div className="atelier-pane atelier-pane--console">
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 12 }}>
|
||||
<div className="atelier-mode-tabs">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={
|
||||
'atelier-mode-tab' + (m === mode ? ' atelier-mode-tab--active' : '')
|
||||
}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: '#9a9a9d' }}>
|
||||
{entries.length === 0
|
||||
? 'No issues'
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* DeployPanel — operator-facing UI to push the current workspace scenario to
|
||||
* a board through the zacus-gateway (board selector + « Déployer » button).
|
||||
*
|
||||
* Boards without an IP that the master relays over ESP-NOW (PLIP, box3,
|
||||
* p7_coffre) are flagged in the selector; the deploy is then routed through
|
||||
* the master automatically (see useDeploy.resolveFlashTarget).
|
||||
*
|
||||
* Pure inline-styles per atelier convention (no Tailwind, no atelier-pane*
|
||||
* class — the panel is self-contained and floats over the editor pane).
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDeploy, resolveFlashTarget, type FlashStep } from '../hooks/useDeploy.js';
|
||||
|
||||
const STEP_COLORS: Record<FlashStep['status'], string> = {
|
||||
ok: '#86efac',
|
||||
warn: '#fcd34d',
|
||||
skip: '#9a9a9d',
|
||||
error: '#fca5a5',
|
||||
};
|
||||
|
||||
export function DeployPanel() {
|
||||
const {
|
||||
boards,
|
||||
loadingBoards,
|
||||
deploying,
|
||||
result,
|
||||
error,
|
||||
routedVia,
|
||||
deploy,
|
||||
refetchBoards,
|
||||
gatewayBaseUrl,
|
||||
} = useDeploy();
|
||||
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
|
||||
// Default to the first board once the list lands — never override a choice.
|
||||
useEffect(() => {
|
||||
const first = boards[0];
|
||||
if (!selected && first) setSelected(first.name);
|
||||
}, [boards, selected]);
|
||||
|
||||
const routing = useMemo(
|
||||
() => (selected ? resolveFlashTarget(selected, boards) : null),
|
||||
[selected, boards],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="deploy-panel"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
background: '#1f1f22',
|
||||
border: '1px solid #2c2c2e',
|
||||
borderRadius: 6,
|
||||
color: '#e8e8e8',
|
||||
fontSize: 13,
|
||||
minWidth: 280,
|
||||
maxWidth: 360,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13, letterSpacing: 0.2 }}>
|
||||
Déploiement du scénario
|
||||
</strong>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<select
|
||||
data-testid="deploy-board-select"
|
||||
value={selected}
|
||||
disabled={deploying || loadingBoards || boards.length === 0}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
background: '#232327',
|
||||
color: '#e8e8e8',
|
||||
border: '1px solid #2c2c2e',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{boards.length === 0 ? (
|
||||
<option value="">
|
||||
{loadingBoards ? 'Chargement…' : 'Aucune carte (gateway ?)'}
|
||||
</option>
|
||||
) : (
|
||||
boards.map((b) => {
|
||||
const r = resolveFlashTarget(b.name, boards);
|
||||
return (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.label}
|
||||
{r.viaMaster ? ' — relay ESP-NOW' : ''}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refetchBoards()}
|
||||
disabled={loadingBoards || deploying}
|
||||
title="Recharger la liste des cartes"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#232327',
|
||||
color: '#9a9a9d',
|
||||
fontSize: 11,
|
||||
border: '1px solid #2c2c2e',
|
||||
borderRadius: 4,
|
||||
cursor: loadingBoards ? 'wait' : 'pointer',
|
||||
}}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{routing?.viaMaster ? (
|
||||
<div style={{ fontSize: 10, color: '#9a9a9d' }}>
|
||||
Carte sans IP — poussée via le master (relay ESP-NOW vers «{' '}
|
||||
{selected} »).
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="deploy-button"
|
||||
onClick={() => void deploy(selected)}
|
||||
disabled={deploying || !selected || boards.length === 0}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: deploying ? '#3c3c4a' : '#0071e3',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: deploying || !selected ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{deploying ? 'Déploiement…' : 'Déployer'}
|
||||
</button>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#fca5a5',
|
||||
background: '#2a1414',
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{error.kind === 'network'
|
||||
? `Gateway (${gatewayBaseUrl}) injoignable : ${error.message}`
|
||||
: error.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="deploy-result"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
background: result.ok ? '#0e3a1a' : '#2a1414',
|
||||
color: result.ok ? '#86efac' : '#fca5a5',
|
||||
}}
|
||||
>
|
||||
{result.ok ? 'Déployé' : 'Échec'} — {result.board} (
|
||||
{result.strategy_used})
|
||||
{routedVia ? ` via ${routedVia}` : ''}
|
||||
{result.relayed_to.length > 0
|
||||
? ` → relayé à ${result.relayed_to.join(', ')}`
|
||||
: ''}
|
||||
</div>
|
||||
{result.steps.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: STEP_COLORS[s.status] ?? '#9a9a9d',
|
||||
fontFamily: 'ui-monospace, Menlo, monospace',
|
||||
}}
|
||||
>
|
||||
[{s.status}] {s.label}
|
||||
{s.detail ? ` — ${s.detail}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useSelectionStore } from '../stores/selectionStore.js';
|
||||
import { type FlowGraphData } from '../lib/irSteps.js';
|
||||
import { DeviceScreenView } from './DeviceScreenView.js';
|
||||
import { EMPTY_SCENE } from '../lib/screenSpec.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
/**
|
||||
* Device screen preview (phase C): renders the master LCD for the selected
|
||||
* step (falling back to the entry step) using the shared faithful renderer.
|
||||
*
|
||||
* Presentational: the flow graph is parsed once in {@link WorkbenchPane} and
|
||||
* passed in as a prop; only the selection is read from the store.
|
||||
*/
|
||||
export function DeviceScreen({ graph }: { graph: FlowGraphData }) {
|
||||
const { nodes, entry } = graph;
|
||||
|
||||
const selectedStepId = useSelectionStore((s) => s.selectedStepId);
|
||||
|
||||
const targetId = selectedStepId ?? entry;
|
||||
const node = nodes.find((n) => n.id === targetId);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
padding: 8,
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: WB.label, font: `11px ${WB.fontMono}`, letterSpacing: 1 }}>
|
||||
ÉCRAN · {node?.id ?? '—'}
|
||||
</div>
|
||||
<DeviceScreenView scene={node?.scene ?? EMPTY_SCENE} scale={0.7} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SCREEN, type SceneDraft, type ScreenEffect } from '../lib/screenSpec.js';
|
||||
|
||||
/**
|
||||
* Faithful render of the firmware LCD scene (ST7796 480×320, Workbench
|
||||
* palette, per-field fonts and effect animations). Extracted from
|
||||
* ScreenEditorPanel (P3a) so both the scene editor and the Workbench device
|
||||
* preview share one pixel-accurate renderer. Parameterised by `scale`.
|
||||
*/
|
||||
function symbolAnimation(effect: ScreenEffect): string | undefined {
|
||||
if (effect === 'pulse') return 'zacus-pulse 2.4s ease-in-out infinite';
|
||||
if (effect === 'glitch') return 'zacus-glitch 0.45s steps(2) infinite';
|
||||
return undefined; // gyro is drawn as a ring; none = static
|
||||
}
|
||||
|
||||
export function DeviceScreenView({ scene, scale = 0.66 }: { scene: SceneDraft; scale?: number }) {
|
||||
return (
|
||||
<div style={{ width: SCREEN.width * scale, height: SCREEN.height * scale, alignSelf: 'center' }}>
|
||||
<div style={{
|
||||
width: SCREEN.width, height: SCREEN.height, transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||
background: SCREEN.bg, position: 'relative', borderRadius: 6, overflow: 'hidden',
|
||||
boxShadow: 'inset 0 0 0 2px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{/* Title — Orbitron 40, white, top-center */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 24, left: 12, right: 12, textAlign: 'center',
|
||||
color: SCREEN.title.color, font: `700 40px ${SCREEN.title.font}`,
|
||||
letterSpacing: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{scene.title}</div>
|
||||
|
||||
{/* Symbol / code slot — Orbitron 40, orange, center */}
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
}}>
|
||||
{scene.effect === 'gyro' && scene.symbol && (
|
||||
<span style={{
|
||||
position: 'absolute', width: 120, height: 120, borderRadius: '50%',
|
||||
border: `3px solid ${SCREEN.symbol.color}`, borderTopColor: 'transparent',
|
||||
animation: 'zacus-gyro-spin 3s linear infinite',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
color: SCREEN.symbol.color, font: `700 40px ${SCREEN.symbol.font}`, letterSpacing: 3,
|
||||
animation: symbolAnimation(scene.effect),
|
||||
}}>{scene.symbol}</span>
|
||||
</div>
|
||||
|
||||
{/* Subtitle — IBM Plex Mono 18, grey, bottom-left */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 10, left: 8, right: 8,
|
||||
color: SCREEN.subtitle.color, font: `18px ${SCREEN.subtitle.font}`,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{scene.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type * as Blockly from 'blockly';
|
||||
import type { BlocklyWorkspaceHandle } from './BlocklyWorkspace.js';
|
||||
import { useEditorStore } from '../stores/editorStore.js';
|
||||
import { useValidationStore } from '../stores/validationStore.js';
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore.js';
|
||||
|
||||
const BlocklyWorkspaceLazy = lazy(async () => {
|
||||
const blocksMod = await import('../blocks/index.js');
|
||||
@@ -32,20 +33,32 @@ export function EditorPane() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Release the workspace handle when the editor unmounts so MenuBar actions
|
||||
// never target a disposed Blockly instance.
|
||||
useEffect(() => {
|
||||
return () => useWorkspaceStore.getState().setWorkspace(null);
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceChange = useCallback(
|
||||
async (ws: Blockly.WorkspaceSvg) => {
|
||||
const { exportWorkspaceToYaml } = await import('../lib/yaml-export.js');
|
||||
const yaml = exportWorkspaceToYaml(ws);
|
||||
// Register the live workspace once so MenuBar can import scenarios into
|
||||
// it (getState avoids re-subscribing this hot change listener).
|
||||
if (useWorkspaceStore.getState().workspace !== ws) {
|
||||
useWorkspaceStore.getState().setWorkspace(ws);
|
||||
}
|
||||
|
||||
const { workspaceToBlocksNodes } = await import('../lib/yaml-export.js');
|
||||
const { serializeBlocksDoc } = await import('@zacus/shared');
|
||||
const nodes = workspaceToBlocksNodes(ws);
|
||||
const yaml = serializeBlocksDoc(nodes);
|
||||
setBlocklyJson(yaml);
|
||||
|
||||
clearValidation('schema');
|
||||
clearValidation('compile');
|
||||
|
||||
try {
|
||||
const { parseScenarioYaml } = await import('@zacus/shared');
|
||||
const { validateScenario } = await import('../lib/validator.js');
|
||||
const scenario = parseScenarioYaml(yaml);
|
||||
const result = validateScenario(scenario);
|
||||
const { validateBlocksNodes } = await import('../lib/validator.js');
|
||||
const result = validateBlocksNodes(nodes);
|
||||
result.errors.forEach((msg, i) =>
|
||||
pushValidation({
|
||||
id: `schema-e-${i}`,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useSelectionStore } from '../stores/selectionStore.js';
|
||||
import { type FlowGraphData } from '../lib/irSteps.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
/**
|
||||
* Scenario flow view (phase C, V2): a *spatial* vertical graph rendered with
|
||||
* React Flow. Steps are laid out top-down by BFS depth from the entry; clicking
|
||||
* a node selects it (drives the device-screen preview). The entry step is
|
||||
* marked ▶, the selected step is highlighted in {@link WB.orange}.
|
||||
*
|
||||
* Presentational: the flow graph is parsed once in {@link WorkbenchPane} and
|
||||
* passed in as a prop; only the selection is read/written via the store.
|
||||
*/
|
||||
|
||||
const LEVEL_GAP_Y = 96;
|
||||
const SIBLING_GAP_X = 200;
|
||||
|
||||
/** Data carried by each React Flow node. */
|
||||
interface FlowNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
symbol: string;
|
||||
isEntry: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/** Custom Workbench-styled node with vertical source/target handles. */
|
||||
function StepNode({ data }: NodeProps<Node<FlowNodeData>>) {
|
||||
const { label, symbol, isEntry, selected } = data;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minWidth: 120,
|
||||
padding: '6px 10px',
|
||||
border: `1px solid ${selected ? WB.orange : WB.panelBorder}`,
|
||||
borderRadius: 5,
|
||||
background: selected ? 'rgba(255,136,0,0.16)' : WB.panel,
|
||||
color: selected ? WB.orange : WB.text,
|
||||
font: `12px ${WB.fontMono}`,
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
boxShadow: selected ? `0 0 0 1px ${WB.orange}` : 'none',
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} style={{ background: WB.panelBorder }} />
|
||||
<span style={{ color: WB.orange, width: 12 }}>{isEntry ? '▶' : ''}</span>
|
||||
<span>{label}</span>
|
||||
{symbol && <span style={{ color: WB.label }}>⟦{symbol}⟧</span>}
|
||||
<Handle type="source" position={Position.Bottom} style={{ background: WB.panelBorder }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = { step: StepNode };
|
||||
|
||||
/**
|
||||
* Assign a vertical depth to each node by BFS from the entry. Unreachable nodes
|
||||
* are appended at deeper levels so they are never lost. Back-edges (cycles) are
|
||||
* tolerated: a `seen` set prevents re-enqueueing.
|
||||
*/
|
||||
function computeDepths(graph: FlowGraphData): Map<string, number> {
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const e of graph.edges) {
|
||||
const list = adjacency.get(e.from);
|
||||
if (list) list.push(e.to);
|
||||
else adjacency.set(e.from, [e.to]);
|
||||
}
|
||||
|
||||
const depth = new Map<string, number>();
|
||||
const seen = new Set<string>();
|
||||
const queue: Array<{ id: string; d: number }> = [];
|
||||
|
||||
const enqueueRoot = (id: string, d: number) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
queue.push({ id, d });
|
||||
};
|
||||
|
||||
if (graph.entry) enqueueRoot(graph.entry, 0);
|
||||
|
||||
// Drain the BFS, then seed any still-unvisited node as a new root one level
|
||||
// below the current deepest level, until every node has a depth.
|
||||
const allIds = graph.nodes.map((n) => n.id);
|
||||
let idx = 0;
|
||||
while (seen.size < allIds.length || queue.length > 0) {
|
||||
if (queue.length === 0) {
|
||||
// Pick the next unseen node as a fresh root below the deepest known level.
|
||||
while (idx < allIds.length && seen.has(allIds[idx]!)) idx += 1;
|
||||
if (idx >= allIds.length) break;
|
||||
let maxDepth = -1;
|
||||
for (const d of depth.values()) if (d > maxDepth) maxDepth = d;
|
||||
enqueueRoot(allIds[idx]!, maxDepth + 1);
|
||||
}
|
||||
const cur = queue.shift();
|
||||
if (cur === undefined) continue;
|
||||
depth.set(cur.id, cur.d);
|
||||
for (const next of adjacency.get(cur.id) ?? []) enqueueRoot(next, cur.d + 1);
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
export function FlowGraph({ graph }: { graph: FlowGraphData }) {
|
||||
const selectedStepId = useSelectionStore((s) => s.selectedStepId);
|
||||
const selectStep = useSelectionStore((s) => s.selectStep);
|
||||
|
||||
const rfNodes = useMemo<Array<Node<FlowNodeData>>>(() => {
|
||||
if (graph.nodes.length === 0) return [];
|
||||
const depth = computeDepths(graph);
|
||||
|
||||
// Group node ids by depth level to spread siblings horizontally.
|
||||
const byLevel = new Map<number, string[]>();
|
||||
for (const node of graph.nodes) {
|
||||
const d = depth.get(node.id) ?? 0;
|
||||
const list = byLevel.get(d);
|
||||
if (list) list.push(node.id);
|
||||
else byLevel.set(d, [node.id]);
|
||||
}
|
||||
const indexInLevel = new Map<string, number>();
|
||||
for (const ids of byLevel.values()) {
|
||||
ids.forEach((id, i) => indexInLevel.set(id, i));
|
||||
}
|
||||
|
||||
return graph.nodes.map((node) => {
|
||||
const d = depth.get(node.id) ?? 0;
|
||||
const level = byLevel.get(d) ?? [];
|
||||
const i = indexInLevel.get(node.id) ?? 0;
|
||||
const x = (i - (level.length - 1) / 2) * SIBLING_GAP_X;
|
||||
const y = d * LEVEL_GAP_Y;
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'step',
|
||||
position: { x, y },
|
||||
data: {
|
||||
label: node.id,
|
||||
symbol: node.scene.symbol,
|
||||
isEntry: node.id === graph.entry,
|
||||
selected: node.id === selectedStepId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [graph, selectedStepId]);
|
||||
|
||||
const rfEdges = useMemo<Edge[]>(() => {
|
||||
return graph.edges.map((e, i) => {
|
||||
const active = e.from === selectedStepId;
|
||||
const color = active ? WB.orange : WB.label;
|
||||
return {
|
||||
id: `${e.from}->${e.to}-${i}`,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: 'smoothstep',
|
||||
style: { stroke: color, strokeWidth: active ? 2 : 1 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color },
|
||||
};
|
||||
});
|
||||
}, [graph.edges, selectedStepId]);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_e: React.MouseEvent, node: Node) => selectStep(node.id),
|
||||
[selectStep],
|
||||
);
|
||||
|
||||
if (graph.nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 12, color: WB.label, font: `12px ${WB.fontMono}` }}>
|
||||
Aucune étape — édite le scénario puis lance (Run).
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
style={{ background: WB.bg }}
|
||||
>
|
||||
<Background color={WB.gridSubtle} gap={20} />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
type ImperativePanelHandle,
|
||||
} from 'react-resizable-panels';
|
||||
import { EditorPane } from './EditorPane.js';
|
||||
import { StagePane } from './StagePane.js';
|
||||
import { ConsolePane } from './ConsolePane.js';
|
||||
import { WorkbenchPane } from './WorkbenchPane.js';
|
||||
|
||||
/**
|
||||
* Atelier shell: 2 columns top + 1 console row bottom.
|
||||
@@ -65,7 +65,9 @@ export function Layout() {
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
>
|
||||
<StagePane />
|
||||
<div data-testid="stage-pane" style={{ height: '100%' }}>
|
||||
<WorkbenchPane />
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
|
||||
import { useEditorStore } from '../stores/editorStore.js';
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore.js';
|
||||
import { DEFAULT_SCENARIOS, type DefaultScenario } from '../lib/defaultScenarios.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
import { GameConfigPanel } from './GameConfigPanel.js';
|
||||
import { DeployPanel } from './DeployPanel.js';
|
||||
import { AssetsBody } from './AssetsPanel.js';
|
||||
|
||||
/** Workbench panels reachable from the menu bar. One open at a time. */
|
||||
type PanelId = 'config' | 'deploy' | 'assets';
|
||||
const PANEL_LABELS: Record<PanelId, string> = {
|
||||
config: 'Configuration de groupe',
|
||||
deploy: 'Déploiement',
|
||||
assets: 'Assets',
|
||||
};
|
||||
|
||||
/** Workbench lab title — Orbitron, orange, wide tracking. */
|
||||
const labTitleStyle: CSSProperties = {
|
||||
fontFamily: WB.fontTitle,
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
letterSpacing: 2,
|
||||
color: WB.orange,
|
||||
};
|
||||
|
||||
/** Bar chrome: dark panel with a workbench-blue baseline. */
|
||||
const barStyle: CSSProperties = {
|
||||
background: WB.panel,
|
||||
borderBottom: `1px solid ${WB.panelBorder}`,
|
||||
};
|
||||
|
||||
/** Shared button chrome (hover handled inline). */
|
||||
const btnStyle: CSSProperties = {
|
||||
background: WB.panel,
|
||||
border: `1px solid ${WB.panelBorder}`,
|
||||
color: WB.text,
|
||||
};
|
||||
|
||||
const onBtnEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.borderColor = WB.orange;
|
||||
};
|
||||
const onBtnLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.borderColor = WB.panelBorder;
|
||||
};
|
||||
|
||||
/** Trigger a client-side download of a text blob. */
|
||||
function downloadText(filename: string, text: string, mime = 'text/yaml'): void {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: mime }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** `20260613-1135` — stable, filesystem-safe scenario filename suffix. */
|
||||
function timestampSlug(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||
`-${pad(d.getHours())}${pad(d.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top menu bar: load / save (and later share) a scenario. The scenario YAML
|
||||
* is the `blocks_studio v2` interop format — the same one the gateway compiles
|
||||
* to Runtime 3 IR and the zacus-hub Studio reads/writes.
|
||||
*
|
||||
* Save reads the already-serialized YAML from `editorStore`; load injects a
|
||||
* picked `.yaml` back into the live Blockly workspace (`workspaceStore`).
|
||||
*/
|
||||
export function MenuBar() {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const yaml = useEditorStore((s) => s.blocklyJson);
|
||||
const hasWorkspace = useWorkspaceStore((s) => s.workspace !== null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [openList, setOpenList] = useState(false);
|
||||
const [openPanel, setOpenPanel] = useState<PanelId | null>(null);
|
||||
|
||||
/** Open one panel popover (closing any other) or toggle the current one shut. */
|
||||
const togglePanel = (id: PanelId) => {
|
||||
setOpenList(false);
|
||||
setOpenPanel((cur) => (cur === id ? null : id));
|
||||
};
|
||||
|
||||
const flash = (msg: string) => {
|
||||
setNotice(msg);
|
||||
window.setTimeout(() => setNotice(null), 4000);
|
||||
};
|
||||
|
||||
/** Import a YAML document into the live workspace, reporting via the notice. */
|
||||
const importYaml = async (text: string, okPrefix: string): Promise<void> => {
|
||||
const ws = useWorkspaceStore.getState().workspace;
|
||||
if (!ws) {
|
||||
flash('Éditeur pas encore prêt');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { importScenarioToWorkspace } = await import('../lib/yaml-import.js');
|
||||
const { created, source, warnings } = importScenarioToWorkspace(ws, text);
|
||||
const plural = created > 1 ? 's' : '';
|
||||
if (source === 'runtime3') {
|
||||
const extra = warnings.length > 0 ? ` — ${warnings.length} avertissement${warnings.length > 1 ? 's' : ''}` : '';
|
||||
if (warnings.length > 0) console.warn('[runtime3-import]', ...warnings);
|
||||
flash(`${okPrefix} (Runtime 3) : ${created} bloc${plural}${extra}`);
|
||||
} else {
|
||||
flash(`${okPrefix} : ${created} bloc${plural}`);
|
||||
}
|
||||
} catch (err) {
|
||||
flash(`Échec : ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenScenario = (scenario: DefaultScenario) => {
|
||||
setOpenList(false);
|
||||
void importYaml(scenario.yaml, `« ${scenario.name} »`);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const doc = yaml?.trim();
|
||||
if (!doc) {
|
||||
flash('Espace vide — rien à enregistrer');
|
||||
return;
|
||||
}
|
||||
downloadText(`zacus-scenario-${timestampSlug()}.yaml`, doc);
|
||||
flash('Scénario téléchargé');
|
||||
};
|
||||
|
||||
const onShare = async () => {
|
||||
const doc = yaml?.trim();
|
||||
if (!doc) {
|
||||
flash('Espace vide — rien à partager');
|
||||
return;
|
||||
}
|
||||
setSharing(true);
|
||||
try {
|
||||
const { createShare, shareLinkFor } = await import('../lib/share.js');
|
||||
const link = shareLinkFor(await createShare(doc));
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
flash(`Lien copié : ${link}`);
|
||||
} catch {
|
||||
flash(`Lien de partage : ${link}`);
|
||||
}
|
||||
} catch (err) {
|
||||
flash(`Partage échoué : ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadClick = () => fileRef.current?.click();
|
||||
|
||||
const onFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // allow re-selecting the same file later
|
||||
if (!file) return;
|
||||
await importYaml(await file.text(), 'Chargé');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="atelier-menubar" style={barStyle}>
|
||||
<span className="atelier-menubar__title" style={labTitleStyle}>
|
||||
▦ ZACUS · ATELIER
|
||||
</span>
|
||||
<div className="atelier-menubar__group">
|
||||
<div className="atelier-menubar__dropdown">
|
||||
<button
|
||||
type="button"
|
||||
className="atelier-menubar__btn"
|
||||
style={btnStyle}
|
||||
onMouseEnter={onBtnEnter}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onClick={() => {
|
||||
setOpenPanel(null);
|
||||
setOpenList((v) => !v);
|
||||
}}
|
||||
disabled={!hasWorkspace}
|
||||
title="Ouvrir un scénario de départ"
|
||||
aria-expanded={openList}
|
||||
>
|
||||
Ouvrir ▾
|
||||
</button>
|
||||
{openList && (
|
||||
<>
|
||||
<div
|
||||
className="atelier-menubar__backdrop"
|
||||
onClick={() => setOpenList(false)}
|
||||
/>
|
||||
<ul className="atelier-menubar__list" role="menu">
|
||||
{DEFAULT_SCENARIOS.map((s) => (
|
||||
<li key={s.id} role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="atelier-menubar__item"
|
||||
onClick={() => onOpenScenario(s)}
|
||||
title={s.description}
|
||||
>
|
||||
<span className="atelier-menubar__item-name">{s.name}</span>
|
||||
<span className="atelier-menubar__item-desc">{s.description}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="atelier-menubar__btn"
|
||||
style={btnStyle}
|
||||
onMouseEnter={onBtnEnter}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onClick={onLoadClick}
|
||||
disabled={!hasWorkspace}
|
||||
title="Charger un scénario .yaml dans l'éditeur"
|
||||
>
|
||||
Charger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="atelier-menubar__btn"
|
||||
style={btnStyle}
|
||||
onMouseEnter={onBtnEnter}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onClick={onSave}
|
||||
disabled={!yaml?.trim()}
|
||||
title="Télécharger le scénario courant en .yaml"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="atelier-menubar__btn"
|
||||
style={btnStyle}
|
||||
onMouseEnter={onBtnEnter}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onClick={onShare}
|
||||
disabled={!yaml?.trim() || sharing}
|
||||
title="Publier le scénario et copier un lien de partage"
|
||||
>
|
||||
{sharing ? 'Partage…' : 'Partager'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="atelier-menubar__group atelier-menubar__dropdown">
|
||||
{(Object.keys(PANEL_LABELS) as PanelId[]).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`atelier-menubar__btn${openPanel === id ? ' atelier-menubar__btn--active' : ''}`}
|
||||
style={btnStyle}
|
||||
onMouseEnter={onBtnEnter}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onClick={() => togglePanel(id)}
|
||||
aria-expanded={openPanel === id}
|
||||
title={`Ouvrir : ${PANEL_LABELS[id]}`}
|
||||
>
|
||||
{PANEL_LABELS[id]} ▾
|
||||
</button>
|
||||
))}
|
||||
{openPanel && (
|
||||
<>
|
||||
<div
|
||||
className="atelier-menubar__backdrop"
|
||||
onClick={() => setOpenPanel(null)}
|
||||
/>
|
||||
<div className="atelier-menubar__panel" role="dialog" aria-label={PANEL_LABELS[openPanel]}>
|
||||
{openPanel === 'config' && <GameConfigPanel />}
|
||||
{openPanel === 'deploy' && <DeployPanel />}
|
||||
{openPanel === 'assets' && <AssetsBody />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notice && <span className="atelier-menubar__notice">{notice}</span>}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml,text/yaml"
|
||||
onChange={onFile}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { generateText } from '../lib/textgen.js';
|
||||
import { DeviceScreenView } from './DeviceScreenView.js';
|
||||
import {
|
||||
EFFECTS, EMPTY_SCENE, SCREEN, overLimit, sceneToYaml,
|
||||
type SceneDraft, type ScreenEffect,
|
||||
} from '../lib/screenSpec.js';
|
||||
|
||||
/**
|
||||
* LCD screen (scene) editor body (P3a, hosted in the unified Assets panel).
|
||||
* Edits a step's scene (title/subtitle/symbol/effect) with a faithful preview
|
||||
* of the ST7796 480×320 render (via the shared DeviceScreenView), enforces the
|
||||
* firmware char limits, can draft screen text via ailiance, and emits the
|
||||
* `scene:` YAML block to paste onto a scenario step.
|
||||
*/
|
||||
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
|
||||
|
||||
function CharField({ label, field, value, onChange }: {
|
||||
label: string; field: 'title' | 'subtitle' | 'symbol'; value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const over = overLimit(field, value);
|
||||
const max = field === 'title' ? SCREEN.title.max : field === 'subtitle' ? SCREEN.subtitle.max : SCREEN.symbol.max;
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ display: 'flex', justifyContent: 'space-between', color: C.sub }}>
|
||||
<span>{label}</span>
|
||||
<span style={{ color: over ? '#fca5a5' : C.sub }}>{value.length}/{max}</span>
|
||||
</span>
|
||||
<input value={value} onChange={(e) => onChange(e.target.value)}
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${over ? '#b45454' : C.border}`,
|
||||
borderRadius: 4, padding: '4px 6px', fontSize: 12 }} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenEditorBody() {
|
||||
const [scene, setScene] = useState<SceneDraft>(EMPTY_SCENE);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const set = <K extends keyof SceneDraft>(key: K, value: SceneDraft[K]) =>
|
||||
setScene((s) => ({ ...s, [key]: value }));
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await generateText('scene_text', prompt.trim());
|
||||
set('title', r.text.slice(0, SCREEN.title.max));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyYaml = async () => {
|
||||
await navigator.clipboard.writeText(sceneToYaml(scene)).catch(() => undefined);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<DeviceScreenView scene={scene} />
|
||||
|
||||
<CharField label="Titre" field="title" value={scene.title} onChange={(v) => set('title', v)} />
|
||||
<CharField label="Sous-titre" field="subtitle" value={scene.subtitle} onChange={(v) => set('subtitle', v)} />
|
||||
<CharField label="Symbole" field="symbol" value={scene.symbol} onChange={(v) => set('symbol', v)} />
|
||||
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ color: C.sub }}>Effet</span>
|
||||
<select value={scene.effect} onChange={(e) => set('effect', e.target.value as ScreenEffect)}
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
|
||||
{EFFECTS.map((fx) => <option key={fx.value} value={fx.value}>{fx.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input value={prompt} onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Décris la scène → titre (IA)…"
|
||||
style={{ flex: 1, background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`,
|
||||
borderRadius: 4, padding: '4px 6px', fontSize: 12 }} />
|
||||
<button type="button" onClick={onGenerate} disabled={busy || !prompt.trim()}
|
||||
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: !prompt.trim() ? 0.4 : 1, whiteSpace: 'nowrap' }}>
|
||||
{busy ? '…' : '✶ IA'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
|
||||
|
||||
<button type="button" onClick={copyYaml}
|
||||
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: C.bg, color: C.text, cursor: 'pointer' }}>
|
||||
{copied ? '✓ Copié' : 'Copier le YAML scene'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { lazy, Suspense, useCallback } from 'react';
|
||||
import { useRuntimeStore } from '../stores/runtimeStore.js';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { useValidationStore } from '../stores/validationStore.js';
|
||||
|
||||
const RoomSceneLazy = lazy(async () => {
|
||||
const mod = await import('../scene/RoomScene.js');
|
||||
return { default: mod.RoomScene };
|
||||
});
|
||||
|
||||
const SandboxModeLazy = lazy(async () => {
|
||||
const mod = await import('../modes/SandboxMode.js');
|
||||
return { default: mod.SandboxMode };
|
||||
});
|
||||
|
||||
const DemoModeLazy = lazy(async () => {
|
||||
const mod = await import('../modes/DemoMode.js');
|
||||
return { default: mod.DemoMode };
|
||||
});
|
||||
|
||||
const TestModeLazy = lazy(async () => {
|
||||
const mod = await import('../modes/TestMode.js');
|
||||
return { default: mod.TestMode };
|
||||
});
|
||||
|
||||
export function StagePane() {
|
||||
const isStale = useRuntimeStore((s) => s.isStale);
|
||||
const pendingIr = useRuntimeStore((s) => s.pendingIr);
|
||||
const commitPendingIr = useRuntimeStore((s) => s.commitPendingIr);
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
const loadScenario = useSimStore((s) => s.loadScenario);
|
||||
const hasErrors = useValidationStore((s) =>
|
||||
s.entries.some((e) => e.severity === 'error'),
|
||||
);
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
if (!pendingIr || hasErrors) return;
|
||||
loadScenario(pendingIr);
|
||||
commitPendingIr();
|
||||
}, [pendingIr, hasErrors, loadScenario, commitPendingIr]);
|
||||
|
||||
const ModeOverlay =
|
||||
mode === 'demo' ? DemoModeLazy : mode === 'test' ? TestModeLazy : SandboxModeLazy;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="stage-pane"
|
||||
style={{ position: 'relative', height: '100%', background: '#000' }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="atelier-pane atelier-pane--stage">
|
||||
<span>Loading 3D engine…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RoomSceneLazy />
|
||||
<ModeOverlay />
|
||||
</Suspense>
|
||||
{isStale ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRun}
|
||||
disabled={hasErrors}
|
||||
title={
|
||||
hasErrors
|
||||
? 'Corrige les erreurs de validation pour relancer'
|
||||
: 'Recompiler et rejouer la scène avec le scenario édité'
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
padding: '6px 14px',
|
||||
background: hasErrors ? '#6b6b70' : '#fbbf24',
|
||||
color: '#1a1a1d',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: hasErrors ? 'not-allowed' : 'pointer',
|
||||
zIndex: 20,
|
||||
boxShadow: hasErrors ? 'none' : '0 2px 6px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
{hasErrors ? '⚠ stale (errors)' : '▶ Run'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { generateText, type TextGenKind } from '../lib/textgen.js';
|
||||
|
||||
/**
|
||||
* Text-asset generation body (P1, hosted in the unified Assets panel). Generates
|
||||
* in-voice NPC lines, progressive hints or screen text via the gateway →
|
||||
* ailiance LLM, with a copy-to-clipboard so the author drops it into a block,
|
||||
* npc_phrases.yaml or hints_adaptive.yaml.
|
||||
*/
|
||||
const KINDS: { value: TextGenKind; label: string; placeholder: string }[] = [
|
||||
{ value: 'npc_reply', label: 'Réplique NPC', placeholder: 'Contexte : le joueur vient de résoudre l’énigme du diapason…' },
|
||||
{ value: 'hint', label: 'Indice', placeholder: 'Énigme : piano L-E-F-O-U. Le joueur est bloqué…' },
|
||||
{ value: 'scene_text', label: 'Texte d’écran', placeholder: 'Scène : intro du laboratoire, ambiance mystérieuse…' },
|
||||
{ value: 'freeform', label: 'Libre', placeholder: 'Décris ce que tu veux générer…' },
|
||||
];
|
||||
|
||||
const C = { bg: '#232327', border: '#2c2c2e', text: '#e8e8e8', sub: '#9a9a9d', accent: '#3c3c4a' };
|
||||
|
||||
export function TextGenBody() {
|
||||
const [kind, setKind] = useState<TextGenKind>('npc_reply');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await generateText(kind, prompt.trim());
|
||||
setResult(r.text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
if (result) await navigator.clipboard.writeText(result).catch(() => undefined);
|
||||
};
|
||||
|
||||
const ph = KINDS.find((k) => k.value === kind)?.placeholder ?? '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value as TextGenKind)}
|
||||
style={{ background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`, borderRadius: 4, padding: '4px 6px' }}>
|
||||
{KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
||||
</select>
|
||||
|
||||
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={ph} rows={3}
|
||||
style={{ resize: 'vertical', background: '#1f1f22', color: C.text, border: `1px solid ${C.border}`,
|
||||
borderRadius: 4, padding: 6, fontFamily: 'inherit', fontSize: 12 }} />
|
||||
|
||||
<button type="button" onClick={onGenerate} disabled={busy || !prompt.trim()}
|
||||
style={{ padding: '6px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: busy ? C.bg : C.accent, color: '#fff', cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: !prompt.trim() ? 0.4 : 1 }}>
|
||||
{busy ? 'Génération…' : 'Générer'}
|
||||
</button>
|
||||
|
||||
{error && <span style={{ color: '#fca5a5' }}>{error}</span>}
|
||||
{result && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ background: '#1a1a1d', border: `1px solid ${C.border}`, borderRadius: 4,
|
||||
padding: 8, whiteSpace: 'pre-wrap', maxHeight: 160, overflowY: 'auto' }}>{result}</div>
|
||||
<button type="button" onClick={copy}
|
||||
style={{ padding: '4px 10px', border: `1px solid ${C.border}`, borderRadius: 5,
|
||||
background: C.bg, color: C.text, cursor: 'pointer', alignSelf: 'flex-start' }}>
|
||||
Copier
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { DeviceScreen } from './DeviceScreen.js';
|
||||
import { FlowGraph } from './FlowGraph.js';
|
||||
import { useRuntimeStore } from '../stores/runtimeStore.js';
|
||||
import { parseIr } from '../lib/irSteps.js';
|
||||
import { WB } from '../theme/workbench.js';
|
||||
|
||||
/**
|
||||
* Workbench pane (phase C): the right column of the atelier. A vertical split
|
||||
* with the faithful device-screen preview on top and the scenario flow graph
|
||||
* below.
|
||||
*
|
||||
* The editor document is parsed into a flow graph exactly once here, then
|
||||
* shared with both children (device preview + flow graph) as a prop.
|
||||
*/
|
||||
export function WorkbenchPane() {
|
||||
const ir = useRuntimeStore((s) => s.currentIr ?? s.pendingIr);
|
||||
const graph = useMemo(() => parseIr(ir), [ir]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="atelier-pane"
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
color: WB.orange,
|
||||
font: `700 13px ${WB.fontTitle}`,
|
||||
letterSpacing: 2,
|
||||
borderBottom: `1px solid ${WB.panelBorder}`,
|
||||
}}
|
||||
>
|
||||
BANC D'ESSAI
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={45} minSize={20}>
|
||||
<DeviceScreen graph={graph} />
|
||||
</Panel>
|
||||
<PanelResizeHandle className="atelier-resizer atelier-resizer--horizontal" />
|
||||
<Panel defaultSize={55} minSize={20}>
|
||||
<FlowGraph graph={graph} />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
export const TOOLBOX_XML = `
|
||||
<xml>
|
||||
<category name="Puzzles" colour="#FF6B35">
|
||||
<block type="puzzle_sequence_sonore"/>
|
||||
<block type="puzzle_circuit_led"/>
|
||||
<block type="puzzle_qr_treasure"/>
|
||||
<block type="puzzle_radio"/>
|
||||
<block type="puzzle_morse"/>
|
||||
<block type="puzzle_symboles_nfc"/>
|
||||
<block type="puzzle_coffre_final"/>
|
||||
</category>
|
||||
<category name="NPC" colour="#5B4FD6">
|
||||
<block type="npc_profiling"/>
|
||||
<block type="npc_duration"/>
|
||||
<block type="npc_adaptive_rule"/>
|
||||
</category>
|
||||
<category name="Flow" colour="#2ECC71">
|
||||
<block type="phase_container"/>
|
||||
<block type="puzzle_selector"/>
|
||||
</category>
|
||||
</xml>
|
||||
`;
|
||||
import { ZACUS_TOOLBOX_CATEGORIES } from '@zacus/shared/blockly';
|
||||
|
||||
/**
|
||||
* Toolbox XML generated from the shared catalog — categories and colours
|
||||
* stay in lockstep with the zacus-hub native Studio.
|
||||
*/
|
||||
export const TOOLBOX_XML = `<xml>${ZACUS_TOOLBOX_CATEGORIES.map(
|
||||
(c) =>
|
||||
`<category name="${c.name}" colour="${c.colour}">${c.types
|
||||
.map((t) => `<block type="${t}"/>`)
|
||||
.join('')}</category>`,
|
||||
).join('')}</xml>`;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* useDeploy — push the current Blockly workspace scenario to a board via the
|
||||
* zacus-gateway (tools/zacus-gateway).
|
||||
*
|
||||
* Flow per deploy:
|
||||
* 1. PUT /v1/studio/scenario/atelier_live.yaml { yaml } (save + validate)
|
||||
* 2. POST /v1/flash/{target} { scenario, strategy }
|
||||
*
|
||||
* Routing: a board with no IP (e.g. PLIP, box3) is not reachable over HTTP,
|
||||
* but the master relays scenarios to its `espnow_relay_peers` over ESP-NOW.
|
||||
* When the selected board has no IP and the master lists it as a relay peer,
|
||||
* the flash is routed through the master (strategy "hot"); the gateway then
|
||||
* POSTs /game/scenario/relay on the master, which chunks the IR to the peers.
|
||||
*
|
||||
* Configuration (Vite env, all optional):
|
||||
* VITE_GATEWAY_BASE_URL base URL of the zacus-gateway
|
||||
* (default: http://localhost:8400)
|
||||
* VITE_GATEWAY_TOKEN bearer token (see tools/zacus-gateway/gen_token.py)
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEditorStore } from '../stores/editorStore.js';
|
||||
|
||||
export const DEPLOY_SCENARIO_NAME = 'atelier_live.yaml';
|
||||
|
||||
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
|
||||
|
||||
export interface BoardInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
ip: string | null;
|
||||
mdns: string | null;
|
||||
hot_endpoint: string | null;
|
||||
cold_data_dir: string | null;
|
||||
espnow_relay_peers: string[];
|
||||
}
|
||||
|
||||
export interface FlashStep {
|
||||
label: string;
|
||||
status: 'ok' | 'warn' | 'skip' | 'error';
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface FlashResult {
|
||||
board: string;
|
||||
strategy_used: string;
|
||||
ok: boolean;
|
||||
steps: FlashStep[];
|
||||
ir_path: string | null;
|
||||
cold_command: string | null;
|
||||
relayed_to: string[];
|
||||
}
|
||||
|
||||
export type DeployError =
|
||||
| { kind: 'network'; message: string }
|
||||
| { kind: 'http'; status: number; message: string }
|
||||
| { kind: 'empty'; message: string };
|
||||
|
||||
export interface UseDeployResult {
|
||||
/** Boards declared in the gateway's boards.yaml (empty until loaded). */
|
||||
boards: BoardInfo[];
|
||||
/** True while the board list is loading. */
|
||||
loadingBoards: boolean;
|
||||
/** True while a deploy is in flight. */
|
||||
deploying: boolean;
|
||||
/** Last flash result (null until the first deploy). */
|
||||
result: FlashResult | null;
|
||||
/** Last error (board list or deploy). */
|
||||
error: DeployError | null;
|
||||
/** Board name the last deploy was actually flashed through (master when
|
||||
* relayed), null when it targeted the selected board directly. */
|
||||
routedVia: string | null;
|
||||
/** Push the current workspace scenario to `boardName`. True on success. */
|
||||
deploy: (boardName: string) => Promise<boolean>;
|
||||
/** Re-pull the board list. */
|
||||
refetchBoards: () => Promise<void>;
|
||||
/** Resolved gateway base URL (env merge). */
|
||||
gatewayBaseUrl: string;
|
||||
}
|
||||
|
||||
interface ImportMetaEnvLike {
|
||||
VITE_GATEWAY_BASE_URL?: string | undefined;
|
||||
VITE_GATEWAY_TOKEN?: string | undefined;
|
||||
}
|
||||
|
||||
function readEnv(): ImportMetaEnvLike {
|
||||
// Static `import.meta.env.VITE_*` reads so Vite inlines them at build time;
|
||||
// an aliased/optional-chained access (`(import.meta as any)?.env`) is left as
|
||||
// a runtime lookup that resolves to undefined in the browser.
|
||||
return {
|
||||
VITE_GATEWAY_BASE_URL: import.meta.env.VITE_GATEWAY_BASE_URL,
|
||||
VITE_GATEWAY_TOKEN: import.meta.env.VITE_GATEWAY_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the board the flash should go through. Boards without an IP are
|
||||
* unreachable over HTTP; when the master relays them over ESP-NOW, flash the
|
||||
* master instead (its relay fan-out includes the requested peer). */
|
||||
export function resolveFlashTarget(
|
||||
boardName: string,
|
||||
boards: BoardInfo[],
|
||||
): { target: string; strategy: 'hot' | 'auto'; viaMaster: boolean } {
|
||||
const board = boards.find((b) => b.name === boardName);
|
||||
if (board && !board.ip) {
|
||||
const master = boards.find(
|
||||
(b) => b.ip && b.espnow_relay_peers.includes(boardName),
|
||||
);
|
||||
if (master) return { target: master.name, strategy: 'hot', viaMaster: true };
|
||||
}
|
||||
return { target: boardName, strategy: 'auto', viaMaster: false };
|
||||
}
|
||||
|
||||
export function useDeploy(): UseDeployResult {
|
||||
const env = useMemo(() => readEnv(), []);
|
||||
const gatewayBaseUrl = (env.VITE_GATEWAY_BASE_URL ?? GATEWAY_DEFAULT_BASE_URL)
|
||||
.replace(/\/+$/, '');
|
||||
const token = env.VITE_GATEWAY_TOKEN ?? '';
|
||||
|
||||
const [boards, setBoards] = useState<BoardInfo[]>([]);
|
||||
const [loadingBoards, setLoadingBoards] = useState<boolean>(false);
|
||||
const [deploying, setDeploying] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<FlashResult | null>(null);
|
||||
const [error, setError] = useState<DeployError | null>(null);
|
||||
const [routedVia, setRoutedVia] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const headers = useMemo(
|
||||
() => ({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}),
|
||||
[token],
|
||||
);
|
||||
|
||||
const refetchBoards = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoadingBoards(true);
|
||||
try {
|
||||
const resp = await fetch(`${gatewayBaseUrl}/v1/flash/boards`, {
|
||||
headers,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setError({
|
||||
kind: 'http',
|
||||
status: resp.status,
|
||||
message: `GET /v1/flash/boards → ${resp.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBoards((await resp.json()) as BoardInfo[]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError({ kind: 'network', message });
|
||||
} finally {
|
||||
setLoadingBoards(false);
|
||||
}
|
||||
}, [gatewayBaseUrl, headers]);
|
||||
|
||||
const deploy = useCallback(
|
||||
async (boardName: string): Promise<boolean> => {
|
||||
const yaml = useEditorStore.getState().blocklyJson;
|
||||
if (!yaml || !yaml.trim()) {
|
||||
setError({
|
||||
kind: 'empty',
|
||||
message: 'Espace de travail vide — rien à déployer',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeploying(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setRoutedVia(null);
|
||||
try {
|
||||
// 1. Save + validate the YAML gateway-side.
|
||||
const put = await fetch(
|
||||
`${gatewayBaseUrl}/v1/studio/scenario/${DEPLOY_SCENARIO_NAME}`,
|
||||
{ method: 'PUT', headers, body: JSON.stringify({ yaml }) },
|
||||
);
|
||||
if (!put.ok) {
|
||||
setError({
|
||||
kind: 'http',
|
||||
status: put.status,
|
||||
message: `PUT scénario → ${put.status} ${(await put.text()).slice(0, 200)}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Flash, routed through the master for relay-only boards.
|
||||
const { target, strategy, viaMaster } = resolveFlashTarget(
|
||||
boardName,
|
||||
boards,
|
||||
);
|
||||
const post = await fetch(`${gatewayBaseUrl}/v1/flash/${target}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ scenario: DEPLOY_SCENARIO_NAME, strategy }),
|
||||
});
|
||||
if (!post.ok) {
|
||||
setError({
|
||||
kind: 'http',
|
||||
status: post.status,
|
||||
message: `POST flash → ${post.status} ${(await post.text()).slice(0, 200)}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const flash = (await post.json()) as FlashResult;
|
||||
setResult(flash);
|
||||
setRoutedVia(viaMaster ? target : null);
|
||||
return flash.ok;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError({ kind: 'network', message });
|
||||
return false;
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
},
|
||||
[gatewayBaseUrl, headers, boards],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refetchBoards();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [refetchBoards]);
|
||||
|
||||
return {
|
||||
boards,
|
||||
loadingBoards,
|
||||
deploying,
|
||||
result,
|
||||
error,
|
||||
routedVia,
|
||||
deploy,
|
||||
refetchBoards,
|
||||
gatewayBaseUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Client for the gateway's audio-asset generation endpoints (P2 of the asset
|
||||
* authoring plan). Generation is async: POST /v1/assets/audio/generate returns
|
||||
* a job, which we poll via GET /v1/assets/audio/{id}/status until done; the
|
||||
* resulting WAV is previewed at GET /v1/_staged/{key}. Token-free, same-origin
|
||||
* in prod (zacus.saillant.cc/api), mirroring the P1 text-gen client.
|
||||
*/
|
||||
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
|
||||
|
||||
function gatewayBaseUrl(): string {
|
||||
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
|
||||
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** f5 = cloned Professor Zacus voice (voice-bridge); kokoro = stock voices (vllm-mlx). */
|
||||
export type AudioBackend = 'f5' | 'kokoro';
|
||||
|
||||
export interface AudioGenRequest {
|
||||
text: string;
|
||||
backend: AudioBackend;
|
||||
voice?: string; // kokoro voice id (ignored by f5)
|
||||
key?: string; // optional manifest/phrase id; default = auto slug
|
||||
}
|
||||
|
||||
export interface AudioJob {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
backend: string;
|
||||
staged_key?: string | null;
|
||||
error?: string | null;
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
/** Public URL of a staged clip's WAV (for an <audio> element or download). */
|
||||
export function stagedUrl(key: string): string {
|
||||
return `${gatewayBaseUrl()}/v1/_staged/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
async function startAudioJob(req: AudioGenRequest): Promise<AudioJob> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/audio/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`génération refusée (HTTP ${resp.status})${detail ? ` — ${detail.slice(0, 120)}` : ''}`);
|
||||
}
|
||||
return (await resp.json()) as AudioJob;
|
||||
}
|
||||
|
||||
async function fetchAudioJob(id: string): Promise<AudioJob> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/audio/${encodeURIComponent(id)}/status`);
|
||||
if (!resp.ok) throw new Error(`statut indisponible (HTTP ${resp.status})`);
|
||||
return (await resp.json()) as AudioJob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an audio clip and resolve once the job terminates (done | error).
|
||||
* Polls every `intervalMs`, giving up after `timeoutMs`.
|
||||
*/
|
||||
export async function generateAudio(
|
||||
req: AudioGenRequest,
|
||||
{ intervalMs = 1500, timeoutMs = 120_000 }: { intervalMs?: number; timeoutMs?: number } = {},
|
||||
): Promise<AudioJob> {
|
||||
const job = await startAudioJob(req);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let current = job;
|
||||
while (current.status === 'pending' || current.status === 'running') {
|
||||
if (Date.now() > deadline) throw new Error('délai de génération dépassé');
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
current = await fetchAudioJob(job.id);
|
||||
}
|
||||
if (current.status === 'error') throw new Error(current.error || 'échec de la génération audio');
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Bundled starter scenarios for the Atelier "Ouvrir" menu. Each is a valid
|
||||
* `blocks_studio v2` document (the same format Charger/Enregistrer/Partager
|
||||
* use) so opening one drops a ready-made block graph into the workspace.
|
||||
*
|
||||
* They use only block kinds from the shared catalog (@zacus/shared/blockly,
|
||||
* ZACUS_FIELDS_BY_KIND); unknown fields are ignored on import, so these stay
|
||||
* forward-compatible with catalog tweaks.
|
||||
*/
|
||||
export interface DefaultScenario {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
yaml: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCENARIOS: readonly DefaultScenario[] = [
|
||||
{
|
||||
id: 'mini-enquete',
|
||||
name: 'Mini-enquête (intro → énigme son → fin)',
|
||||
description:
|
||||
"Scénario de démarrage complet : écran maître d'accueil, mot du Professeur, première énigme sonore, transition au bouton, écran de victoire puis fin.",
|
||||
yaml: `blocks_studio_version: 2
|
||||
nodes:
|
||||
- id: intro_start
|
||||
kind: sceneStart
|
||||
position: [40, 40]
|
||||
next: intro_scene
|
||||
params:
|
||||
id: intro
|
||||
- id: intro_scene
|
||||
kind: scene
|
||||
position: [40, 130]
|
||||
next: intro_say
|
||||
params:
|
||||
title: "LABORATOIRE ZACUS"
|
||||
subtitle: "Une transmission vous attend"
|
||||
symbol: "RUN"
|
||||
effect: glitch
|
||||
- id: intro_say
|
||||
kind: npcSay
|
||||
position: [40, 220]
|
||||
next: intro_sound
|
||||
params:
|
||||
text: "Bienvenue. Pour réveiller la machine, reproduisez la mélodie d'amorçage."
|
||||
voice: zacus
|
||||
emotion: mysterious
|
||||
rate: 1
|
||||
- id: intro_sound
|
||||
kind: puzzleSound
|
||||
position: [40, 310]
|
||||
next: intro_goto
|
||||
params:
|
||||
id: 1
|
||||
melody: "60, 62, 64, 65"
|
||||
tolerance: 1
|
||||
fragment: "1"
|
||||
- id: intro_goto
|
||||
kind: onButton
|
||||
position: [40, 400]
|
||||
next: intro_end
|
||||
params:
|
||||
button: NEXT
|
||||
target: victoire
|
||||
- id: intro_end
|
||||
kind: sceneEnd
|
||||
position: [40, 490]
|
||||
- id: win_start
|
||||
kind: sceneStart
|
||||
position: [400, 40]
|
||||
next: win_scene
|
||||
params:
|
||||
id: victoire
|
||||
- id: win_scene
|
||||
kind: scene
|
||||
position: [400, 130]
|
||||
next: win_sound
|
||||
params:
|
||||
title: "SIGNAL STABILISÉ"
|
||||
subtitle: "Première énigme résolue"
|
||||
symbol: "WIN"
|
||||
effect: pulse
|
||||
- id: win_sound
|
||||
kind: hwSoundPlay
|
||||
position: [400, 220]
|
||||
next: win_end
|
||||
params:
|
||||
asset: sting_win
|
||||
- id: win_end
|
||||
kind: sceneEnd
|
||||
position: [400, 310]
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'intro-sonore',
|
||||
name: 'Intro + énigme sonore',
|
||||
description: "Accueil du Professeur puis première énigme : reproduire une séquence sonore.",
|
||||
yaml: `blocks_studio_version: 2
|
||||
nodes:
|
||||
- id: s1
|
||||
kind: sceneStart
|
||||
position: [40, 40]
|
||||
next: s2
|
||||
params:
|
||||
id: intro
|
||||
- id: s2
|
||||
kind: npcSay
|
||||
position: [40, 130]
|
||||
next: s3
|
||||
params:
|
||||
text: "Bienvenue dans mon laboratoire... le mystère commence !"
|
||||
- id: s3
|
||||
kind: hwSoundPlay
|
||||
position: [40, 220]
|
||||
next: s4
|
||||
params:
|
||||
asset: ambiance_labo
|
||||
- id: s4
|
||||
kind: puzzleSound
|
||||
position: [40, 310]
|
||||
next: s5
|
||||
params:
|
||||
id: P1_SON
|
||||
melody: "do re mi sol"
|
||||
tolerance: 1
|
||||
fragment: A
|
||||
- id: s5
|
||||
kind: sceneEnd
|
||||
position: [40, 410]
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'maitre-du-jeu',
|
||||
name: 'Maître du jeu adaptatif',
|
||||
description: "Phase de jeu rythmée : ambiance NPC, indices proactifs et bonus de score selon la progression.",
|
||||
yaml: `blocks_studio_version: 2
|
||||
nodes:
|
||||
- id: g1
|
||||
kind: phase
|
||||
position: [40, 40]
|
||||
next: g2
|
||||
params:
|
||||
id: enquete
|
||||
duration_minutes: 20
|
||||
- id: g2
|
||||
kind: npcSetMood
|
||||
position: [40, 130]
|
||||
next: g3
|
||||
params:
|
||||
mood: mysterious
|
||||
- id: g3
|
||||
kind: proactiveHint
|
||||
position: [40, 220]
|
||||
next: g4
|
||||
params:
|
||||
interval_seconds: 180
|
||||
hint_key: P1
|
||||
- id: g4
|
||||
kind: gmRule
|
||||
position: [40, 310]
|
||||
next: g5
|
||||
params:
|
||||
condition: "score < 3"
|
||||
action: encourage
|
||||
- id: g5
|
||||
kind: scoreBonus
|
||||
position: [40, 400]
|
||||
params:
|
||||
condition: "score > 5"
|
||||
points: 10
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'enigmes-coffre',
|
||||
name: 'Énigmes → assemblage du coffre',
|
||||
description: "Chaîne de trois énigmes (QR, morse, clavier) dont les fragments assemblent le code du coffre final.",
|
||||
yaml: `blocks_studio_version: 2
|
||||
nodes:
|
||||
- id: p1
|
||||
kind: puzzleQR
|
||||
position: [40, 40]
|
||||
next: p2
|
||||
params:
|
||||
id: P2_QR
|
||||
codes: "ZACUS-2026"
|
||||
fragment: A
|
||||
- id: p2
|
||||
kind: puzzleMorse
|
||||
position: [40, 130]
|
||||
next: p3
|
||||
params:
|
||||
id: P5_MORSE
|
||||
message: SOS
|
||||
fragment: B
|
||||
- id: p3
|
||||
kind: puzzleCoffre
|
||||
position: [40, 220]
|
||||
next: p4
|
||||
params:
|
||||
id: P7_COFFRE
|
||||
length: 4
|
||||
fragment: C
|
||||
- id: p4
|
||||
kind: codeAssembly
|
||||
position: [40, 310]
|
||||
next: p5
|
||||
params:
|
||||
length: 3
|
||||
fragments: "A,B,C"
|
||||
- id: p5
|
||||
kind: coffreVerify
|
||||
position: [40, 400]
|
||||
params:
|
||||
id: P7_COFFRE
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,127 @@
|
||||
import { parseBlocksDocYaml, type BlocksNode } from '@zacus/shared';
|
||||
import { type SceneDraft, type ScreenEffect } from './screenSpec.js';
|
||||
|
||||
/**
|
||||
* Flow-graph model derived from the editor's live document (runtimeStore's
|
||||
* `currentIr` / `pendingIr`).
|
||||
*
|
||||
* NB: despite the field name, that string is **not** a compiled Runtime 3 IR —
|
||||
* it is the `blocks_studio_version: 2` document emitted by the Blockly editor
|
||||
* (`serializeBlocksDoc`). A "step" is a `sceneStart` node; the chain that
|
||||
* follows it (via `next`, until the next `sceneStart`) carries its master
|
||||
* screen (`scene` block) and its outgoing transitions (`sceneGoto`,
|
||||
* `sceneBranch`, `npcIntentMatch`). The entry step is the first `sceneStart`.
|
||||
*/
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
scene: SceneDraft;
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Parsed flow graph: the result of {@link parseIr}. */
|
||||
export interface FlowGraphData {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
entry: string | null;
|
||||
}
|
||||
|
||||
const VALID_EFFECTS: readonly ScreenEffect[] = ['pulse', 'glitch', 'gyro', 'none'];
|
||||
|
||||
function toEffect(value: unknown): ScreenEffect {
|
||||
return VALID_EFFECTS.includes(value as ScreenEffect) ? (value as ScreenEffect) : 'pulse';
|
||||
}
|
||||
|
||||
function param(node: BlocksNode, key: string): string {
|
||||
const v = node.params?.[key];
|
||||
return v === undefined || v === null ? '' : String(v).trim();
|
||||
}
|
||||
|
||||
/** Heads reachable from a node: its `next` plus any c-block slot heads. */
|
||||
function chainHeads(node: BlocksNode): string[] {
|
||||
const heads: string[] = [];
|
||||
if (node.next) heads.push(node.next);
|
||||
for (const head of Object.values(node.slots ?? {})) heads.push(head);
|
||||
return heads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the editor document into flow nodes + edges. Invalid / empty input
|
||||
* yields an empty graph. Edges only keep targets that resolve to a known step.
|
||||
*/
|
||||
export function parseIr(irJson: string | null): FlowGraphData {
|
||||
if (!irJson) return { nodes: [], edges: [], entry: null };
|
||||
|
||||
let nodes: BlocksNode[];
|
||||
try {
|
||||
nodes = parseBlocksDocYaml(irJson).nodes;
|
||||
} catch {
|
||||
return { nodes: [], edges: [], entry: null };
|
||||
}
|
||||
|
||||
const byId: Record<string, BlocksNode> = {};
|
||||
for (const n of nodes) byId[n.id] = n;
|
||||
|
||||
// Steps are sceneStart nodes, keyed by their declared scene id.
|
||||
const flowNodes: FlowNode[] = [];
|
||||
const stepIds = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
if (n.kind !== 'sceneStart') continue;
|
||||
const id = param(n, 'id');
|
||||
if (!id || stepIds.has(id)) continue;
|
||||
stepIds.add(id);
|
||||
flowNodes.push({ id, scene: { title: '', subtitle: '', symbol: '', effect: 'pulse' } });
|
||||
}
|
||||
|
||||
const entry = flowNodes[0]?.id ?? null;
|
||||
|
||||
// Walk each step's chain (following next + slots, stopping at the next
|
||||
// sceneStart) to collect its master screen and outgoing transitions.
|
||||
const rawEdges: FlowEdge[] = [];
|
||||
for (const start of nodes) {
|
||||
if (start.kind !== 'sceneStart') continue;
|
||||
const from = param(start, 'id');
|
||||
if (!from) continue;
|
||||
const node = flowNodes.find((fn) => fn.id === from);
|
||||
|
||||
const seen = new Set<string>([start.id]);
|
||||
const queue = chainHeads(start);
|
||||
while (queue.length > 0) {
|
||||
const headId = queue.shift();
|
||||
if (headId === undefined || seen.has(headId)) continue;
|
||||
seen.add(headId);
|
||||
const cur = byId[headId];
|
||||
if (!cur || cur.kind === 'sceneStart') continue; // next step owns it
|
||||
|
||||
if (cur.kind === 'scene' && node) {
|
||||
node.scene = {
|
||||
title: param(cur, 'title'),
|
||||
subtitle: param(cur, 'subtitle'),
|
||||
symbol: param(cur, 'symbol'),
|
||||
effect: toEffect(cur.params?.['effect']),
|
||||
};
|
||||
} else if (cur.kind === 'sceneGoto') {
|
||||
const to = param(cur, 'target');
|
||||
if (to) rawEdges.push({ from, to });
|
||||
} else if (cur.kind === 'sceneBranch') {
|
||||
for (const key of ['ifTrue', 'ifFalse']) {
|
||||
const to = param(cur, key);
|
||||
if (to) rawEdges.push({ from, to });
|
||||
}
|
||||
} else if (cur.kind === 'npcIntentMatch') {
|
||||
const to = param(cur, 'then');
|
||||
if (to) rawEdges.push({ from, to });
|
||||
}
|
||||
|
||||
for (const h of chainHeads(cur)) queue.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only edges whose target resolves to a known step.
|
||||
const edges = rawEdges.filter((e) => stepIds.has(e.to));
|
||||
|
||||
return { nodes: flowNodes, edges, entry };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBlocksDocYaml } from '@zacus/shared';
|
||||
import { runtime3ToBlocksDoc } from './runtime3-import';
|
||||
|
||||
/** Convenience: convert and re-parse so we assert on the node graph. */
|
||||
function convert(yaml: string) {
|
||||
const { doc, warnings, stepCount } = runtime3ToBlocksDoc(yaml);
|
||||
return { nodes: parseBlocksDocYaml(doc).nodes, warnings, stepCount };
|
||||
}
|
||||
|
||||
describe('runtime3ToBlocksDoc', () => {
|
||||
it('maps firmware.steps with structured transitions to sceneStart→on*→sceneEnd chains', () => {
|
||||
const yaml = `
|
||||
firmware:
|
||||
initial_step: STEP_A
|
||||
steps:
|
||||
- step_id: STEP_A
|
||||
transitions:
|
||||
- event_type: serial
|
||||
event_name: BTN_NEXT
|
||||
target_step_id: STEP_B
|
||||
- step_id: STEP_B
|
||||
transitions: []
|
||||
`;
|
||||
const { nodes, stepCount } = convert(yaml);
|
||||
expect(stepCount).toBe(2);
|
||||
const kinds = nodes.map((n) => n.kind);
|
||||
// each step: sceneStart + (transitions) + sceneEnd
|
||||
expect(kinds.filter((k) => k === 'sceneStart')).toHaveLength(2);
|
||||
expect(kinds.filter((k) => k === 'sceneEnd')).toHaveLength(2);
|
||||
const onSerial = nodes.find((n) => n.kind === 'onSerial');
|
||||
expect(onSerial?.params).toMatchObject({ token: 'BTN_NEXT', target: 'B' });
|
||||
// sceneStart id is stripped of the STEP_ prefix so it round-trips.
|
||||
expect(nodes.find((n) => n.kind === 'sceneStart')?.params?.['id']).toBe('A');
|
||||
});
|
||||
|
||||
it('emits an inline scene block and a sound puzzle block', () => {
|
||||
const yaml = `
|
||||
firmware:
|
||||
steps:
|
||||
- step_id: STEP_SON
|
||||
scene:
|
||||
title: PROTO U-SON
|
||||
subtitle: transmission active
|
||||
symbol: ALERT
|
||||
effect: glitch
|
||||
puzzle:
|
||||
id: 1
|
||||
type: sound
|
||||
melody: [60, 62, 64]
|
||||
tolerance: 1
|
||||
fragment: "12"
|
||||
transitions: []
|
||||
`;
|
||||
const { nodes } = convert(yaml);
|
||||
const scene = nodes.find((n) => n.kind === 'scene');
|
||||
expect(scene?.params).toMatchObject({
|
||||
title: 'PROTO U-SON',
|
||||
subtitle: 'transmission active',
|
||||
symbol: 'ALERT',
|
||||
effect: 'glitch',
|
||||
});
|
||||
const puzzle = nodes.find((n) => n.kind === 'puzzleSound');
|
||||
expect(puzzle?.params).toMatchObject({ melody: '60, 62, 64', tolerance: 1, fragment: '12' });
|
||||
});
|
||||
|
||||
it('joins a scene catalog (lvgl) by screen_scene_id', () => {
|
||||
const yaml = `
|
||||
scenes:
|
||||
- scene_id: SCENE_X
|
||||
lvgl:
|
||||
title: TITRE X
|
||||
subtitle: sous-titre
|
||||
transition:
|
||||
effect: pulse
|
||||
firmware:
|
||||
steps:
|
||||
- step_id: STEP_X
|
||||
screen_scene_id: SCENE_X
|
||||
transitions: []
|
||||
`;
|
||||
const { nodes } = convert(yaml);
|
||||
const scene = nodes.find((n) => n.kind === 'scene');
|
||||
expect(scene?.params).toMatchObject({ title: 'TITRE X', subtitle: 'sous-titre', effect: 'pulse' });
|
||||
});
|
||||
|
||||
it('parses compact-string transitions and routes a QR puzzle', () => {
|
||||
const yaml = `
|
||||
prompt_input:
|
||||
steps_runtime_order:
|
||||
- step_id: STEP_QR
|
||||
screen_scene_id: SCENE_QR
|
||||
puzzle:
|
||||
id: 3
|
||||
type: qr
|
||||
codes: [WIN, ZAC-A1]
|
||||
transitions:
|
||||
- "unlock:UNLOCK_QR->STEP_FINAL"
|
||||
- step_id: STEP_FINAL
|
||||
transitions: []
|
||||
`;
|
||||
const { nodes, stepCount } = convert(yaml);
|
||||
expect(stepCount).toBe(2);
|
||||
expect(nodes.find((n) => n.kind === 'puzzleQR')?.params?.['codes']).toBe('WIN, ZAC-A1');
|
||||
expect(nodes.find((n) => n.kind === 'onUnlock')?.params).toMatchObject({
|
||||
source: 'UNLOCK_QR',
|
||||
target: 'FINAL',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to sceneGoto for non-firmware event types (timer/action)', () => {
|
||||
const yaml = `
|
||||
firmware:
|
||||
steps:
|
||||
- step_id: STEP_A
|
||||
transitions:
|
||||
- event_type: timer
|
||||
event_name: WIN_DUE
|
||||
target_step_id: STEP_B
|
||||
- step_id: STEP_B
|
||||
transitions: []
|
||||
`;
|
||||
const { nodes } = convert(yaml);
|
||||
const goto = nodes.find((n) => n.kind === 'sceneGoto');
|
||||
expect(goto?.params).toMatchObject({ target: 'B' });
|
||||
});
|
||||
|
||||
it('reports unmappable features as warnings instead of crashing', () => {
|
||||
const yaml = `
|
||||
firmware:
|
||||
steps:
|
||||
- step_id: STEP_A
|
||||
audio_pack_id: PACK_BOOT
|
||||
narrative: "long story"
|
||||
puzzle:
|
||||
id: 2
|
||||
type: led
|
||||
pattern: "R1, LED"
|
||||
transitions: []
|
||||
`;
|
||||
const { warnings } = convert(yaml);
|
||||
expect(warnings.some((w) => w.includes('audio_pack_id'))).toBe(true);
|
||||
expect(warnings.some((w) => w.includes('narrative'))).toBe(true);
|
||||
// led puzzle has no faithful block → skipped with a warning.
|
||||
expect(warnings.some((w) => w.toLowerCase().includes('led') || w.includes('no authoring block'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws a clear error when no step list is present (e.g. a scene catalog file)', () => {
|
||||
const yaml = `
|
||||
scenes:
|
||||
- scene_id: SCENE_ONLY
|
||||
lvgl:
|
||||
title: T
|
||||
`;
|
||||
expect(() => runtime3ToBlocksDoc(yaml)).toThrow(/aucune liste/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* runtime3-import — best-effort importer for the *canonical, human-authored*
|
||||
* Runtime 3 scenarios (e.g. `game/scenarios/zacus_v2.yaml`,
|
||||
* `scene_editor_all.yaml`, `scenario_reel_template.yaml`) into the Atelier
|
||||
* Blockly editor.
|
||||
*
|
||||
* The editor natively understands only the flat `blocks_studio_version: 2`
|
||||
* document (see blocks-doc.ts). Canonical scenarios use a completely different
|
||||
* shape: a mapping with a list of *steps*, where each step has an id, an
|
||||
* optional `scene` object {title, subtitle, symbol, effect}, an optional
|
||||
* `puzzle` object {type: qr|sound|…}, and transitions expressed either as
|
||||
* structured objects ({event_type, event_name, target_step_id}) or as compact
|
||||
* strings (`serial:BTN_NEXT->STEP_X`).
|
||||
*
|
||||
* `runtime3ToBlocksDoc()` parses that human format and maps each step to a
|
||||
* chain of blocks:
|
||||
*
|
||||
* sceneStart(id)
|
||||
* → scene(title, subtitle, symbol, effect) [if a scene object]
|
||||
* → puzzleQR/puzzleSound(...) [if a puzzle object]
|
||||
* → sceneGoto / on{Button,AudioDone,Unlock,…}(...) [one per transition]
|
||||
* → sceneEnd
|
||||
*
|
||||
* Anything that has no block equivalent (hints, npc dialogue, acts, timeboxes,
|
||||
* led policies, fx timelines, audio packs, unknown puzzle types, …) is skipped
|
||||
* cleanly and reported in `warnings`. The function never throws on a
|
||||
* well-formed-but-unmappable scenario; it throws only when it cannot find any
|
||||
* recognisable list of steps at all.
|
||||
*
|
||||
* The produced document is a valid `blocks_studio_version: 2` string that the
|
||||
* existing importYamlToWorkspace() / the gateway compiler can consume.
|
||||
*/
|
||||
import { load } from 'js-yaml';
|
||||
import { serializeBlocksDoc, type BlocksNode } from '@zacus/shared';
|
||||
|
||||
export interface Runtime3ImportResult {
|
||||
/** A serialized `blocks_studio_version: 2` document. */
|
||||
doc: string;
|
||||
/** Best-effort mapping notes: skipped features, clamped values, etc. */
|
||||
warnings: string[];
|
||||
/** Number of canonical steps that produced a sceneStart chain. */
|
||||
stepCount: number;
|
||||
}
|
||||
|
||||
type Yaml = Record<string, unknown>;
|
||||
|
||||
const isObject = (v: unknown): v is Yaml =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
const asString = (v: unknown): string =>
|
||||
v === null || v === undefined ? '' : String(v);
|
||||
|
||||
/**
|
||||
* A canonical step id like `STEP_LA_DETECTOR` or scene id `SCENE_QR` carries a
|
||||
* conventional prefix. Blockly's sceneStart `id` and the `target` of
|
||||
* sceneGoto/on* blocks are bare names that the gateway re-prefixes with
|
||||
* `STEP_`. Strip a leading STEP_/SCENE_ so ids round-trip to the same step.
|
||||
*/
|
||||
function bareId(raw: string): string {
|
||||
const s = raw.trim();
|
||||
return s.replace(/^STEP_/, '').replace(/^SCENE_/, '');
|
||||
}
|
||||
|
||||
/** Real firmware event types understood by the gateway/EVENT_TYPES set. */
|
||||
const KNOWN_EVENT_TYPES = new Set([
|
||||
'button',
|
||||
'serial',
|
||||
'timer',
|
||||
'audio_done',
|
||||
'unlock',
|
||||
'espnow',
|
||||
'action',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Locate the list of steps in a canonical scenario. Several human layouts ship
|
||||
* the steps under different keys; probe them in order of specificity.
|
||||
* `scenario_reel_template.yaml` nests them under `prompt_input`.
|
||||
*/
|
||||
function findSteps(doc: Yaml): { steps: Yaml[]; sourceKey: string } | null {
|
||||
const candidates: Array<[string, unknown]> = [];
|
||||
const push = (key: string, holder: unknown) => {
|
||||
if (isObject(holder)) candidates.push([key, (holder as Yaml)[key]]);
|
||||
};
|
||||
|
||||
// zacus_v2 style: firmware.steps
|
||||
push('steps', doc['firmware']);
|
||||
// template style: prompt_input.steps_runtime_order
|
||||
push('steps_runtime_order', doc['prompt_input']);
|
||||
// top-level fallbacks
|
||||
candidates.push(['steps', doc['steps']]);
|
||||
candidates.push(['steps_runtime_order', doc['steps_runtime_order']]);
|
||||
candidates.push(['steps_narrative', doc['steps_narrative']]);
|
||||
|
||||
for (const [key, value] of candidates) {
|
||||
if (Array.isArray(value) && value.some(isObject)) {
|
||||
return { steps: value.filter(isObject) as Yaml[], sourceKey: key };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an index of scene catalog objects keyed by scene id. Canonical
|
||||
* scenarios keep the visual scene definitions (title/subtitle/symbol/effect)
|
||||
* separate from the runtime steps — either inline on the step (`scene: {…}`),
|
||||
* under a top-level `scenes:` list (scene_editor_all, with the fields under
|
||||
* `lvgl`), or under a `scene_screen_audio_catalog_all`. Steps reference them by
|
||||
* `screen_scene_id` / `scene` (a string).
|
||||
*/
|
||||
function buildSceneCatalog(doc: Yaml): Map<string, Yaml> {
|
||||
const out = new Map<string, Yaml>();
|
||||
const lists: unknown[] = [doc['scenes']];
|
||||
const promptInput = doc['prompt_input'];
|
||||
if (isObject(promptInput)) lists.push(promptInput['scene_screen_audio_catalog_all']);
|
||||
for (const list of lists) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const entry of list) {
|
||||
if (!isObject(entry)) continue;
|
||||
const id = asString(entry['scene_id'] ?? entry['id']).trim();
|
||||
if (id) out.set(id, entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extract {title, subtitle, symbol, effect} from a scene object or catalog
|
||||
* entry. Catalog entries (scene_editor_all) nest them under `lvgl`. Returns
|
||||
* null when nothing usable is present. */
|
||||
function sceneParams(source: Yaml | undefined): Record<string, string> | null {
|
||||
if (!source) return null;
|
||||
const lvgl = isObject(source['lvgl']) ? (source['lvgl'] as Yaml) : source;
|
||||
const title = asString(lvgl['title']).trim();
|
||||
const subtitle = asString(lvgl['subtitle']).trim();
|
||||
const symbol = asString(lvgl['symbol']).trim();
|
||||
// effect can live on the scene object, its lvgl.transition, or lvgl.fx.
|
||||
let effect = asString(source['effect'] ?? lvgl['effect']).trim();
|
||||
if (!effect && isObject(lvgl['transition'])) {
|
||||
effect = asString((lvgl['transition'] as Yaml)['effect']).trim();
|
||||
}
|
||||
const params: Record<string, string> = {};
|
||||
if (title) params['title'] = title;
|
||||
if (subtitle) params['subtitle'] = subtitle;
|
||||
if (symbol) params['symbol'] = symbol;
|
||||
// The scene block only accepts pulse/glitch/gyro/none; the compiler clamps
|
||||
// anything else (with its own warning), so pass it through verbatim.
|
||||
if (effect) params['effect'] = effect;
|
||||
return Object.keys(params).length > 0 ? params : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a canonical puzzle object to a puzzle block node. Only `qr` and `sound`
|
||||
* have a faithful authoring block today; other types are reported and skipped.
|
||||
* Returns null + a warning for unmappable puzzles.
|
||||
*/
|
||||
function puzzleNode(
|
||||
puzzle: Yaml,
|
||||
id: string,
|
||||
warnings: string[],
|
||||
pos: [number, number],
|
||||
): BlocksNode | null {
|
||||
const rawType = asString(puzzle['type']).trim().toLowerCase();
|
||||
const slot =
|
||||
typeof puzzle['id'] === 'number'
|
||||
? puzzle['id']
|
||||
: Number(asString(puzzle['id'])) || undefined;
|
||||
const fragment = asString(puzzle['fragment']).trim();
|
||||
|
||||
// The canonical `type` is a free-text descriptor in some files
|
||||
// (e.g. "audio / stabilisation"). Match on substrings.
|
||||
if (rawType.includes('qr') || rawType.includes('scan')) {
|
||||
const codes = Array.isArray(puzzle['codes'])
|
||||
? (puzzle['codes'] as unknown[]).map(asString).join(', ')
|
||||
: asString(puzzle['qr_payload'] ?? puzzle['expected_payload'] ?? puzzle['codes']).trim();
|
||||
if (!codes) {
|
||||
warnings.push(`${id}: QR puzzle without codes/payload — block skipped`);
|
||||
return null;
|
||||
}
|
||||
const params: Record<string, string | number> = { codes };
|
||||
if (slot !== undefined) params['id'] = slot;
|
||||
if (fragment) params['fragment'] = fragment;
|
||||
return { id: `${id}__puzzle`, kind: 'puzzleQR', position: pos, params };
|
||||
}
|
||||
|
||||
if (rawType.includes('sound') || rawType.includes('audio') || rawType.includes('son')) {
|
||||
const melody = Array.isArray(puzzle['melody'])
|
||||
? (puzzle['melody'] as unknown[]).map(asString).join(', ')
|
||||
: asString(puzzle['melody']).trim();
|
||||
if (!melody) {
|
||||
warnings.push(
|
||||
`${id}: sound puzzle "${rawType}" has no melody (notes) — block skipped`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const params: Record<string, string | number> = { melody };
|
||||
if (slot !== undefined) params['id'] = slot;
|
||||
const tol = asString(puzzle['tolerance']).trim();
|
||||
if (tol) params['tolerance'] = Number(tol) || 0;
|
||||
if (fragment) params['fragment'] = fragment;
|
||||
return { id: `${id}__puzzle`, kind: 'puzzleSound', position: pos, params };
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`${id}: puzzle type "${rawType || '(none)'}" has no authoring block — skipped`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a transition (object or compact string) into an event_type, raw target
|
||||
* id and optional event_name. Returns null when no target can be determined.
|
||||
*/
|
||||
function parseTransition(
|
||||
raw: unknown,
|
||||
): { eventType: string; eventName: string; target: string } | null {
|
||||
if (typeof raw === 'string') {
|
||||
// Compact form: "<event_type>:<event_name>-><target_step_id>".
|
||||
const arrowIdx = raw.indexOf('->');
|
||||
if (arrowIdx < 0) return null;
|
||||
const head = raw.slice(0, arrowIdx);
|
||||
const target = raw.slice(arrowIdx + 2).trim();
|
||||
if (!target) return null;
|
||||
const colonIdx = head.indexOf(':');
|
||||
const eventType = colonIdx >= 0 ? head.slice(0, colonIdx) : 'serial';
|
||||
const eventName = colonIdx >= 0 ? head.slice(colonIdx + 1) : head;
|
||||
return {
|
||||
eventType: eventType.trim().toLowerCase(),
|
||||
eventName: eventName.trim(),
|
||||
target,
|
||||
};
|
||||
}
|
||||
if (isObject(raw)) {
|
||||
const target = asString(
|
||||
raw['target_step_id'] ?? raw['target'] ?? raw['goto'] ?? raw['next'],
|
||||
).trim();
|
||||
if (!target) return null;
|
||||
return {
|
||||
eventType: asString(raw['event_type']).trim().toLowerCase() || 'serial',
|
||||
eventName: asString(raw['event_name']).trim(),
|
||||
target,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a parsed transition to the matching authoring block. Real firmware event
|
||||
* types get the dedicated « Quand … → aller à » blocks; everything else falls
|
||||
* back to sceneGoto (the compiler turns it into an `action:goto` transition).
|
||||
*/
|
||||
function transitionNode(
|
||||
t: { eventType: string; eventName: string; target: string },
|
||||
nodeId: string,
|
||||
pos: [number, number],
|
||||
): BlocksNode {
|
||||
const target = bareId(t.target);
|
||||
const base = { id: nodeId, position: pos } as const;
|
||||
switch (t.eventType) {
|
||||
case 'button':
|
||||
return { ...base, kind: 'onButton', params: { button: t.eventName || 'NEXT', target } };
|
||||
case 'audio_done':
|
||||
return { ...base, kind: 'onAudioDone', params: { target } };
|
||||
case 'unlock':
|
||||
return { ...base, kind: 'onUnlock', params: { source: t.eventName || 'door', target } };
|
||||
case 'serial':
|
||||
return { ...base, kind: 'onSerial', params: { token: t.eventName || 'OK', target } };
|
||||
case 'espnow':
|
||||
return { ...base, kind: 'onEspnow', params: { command: t.eventName || 'ready', target } };
|
||||
default:
|
||||
// timer / action / unknown → plain goto.
|
||||
return { ...base, kind: 'sceneGoto', params: { target } };
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_DX = 360; // horizontal gap between step chains
|
||||
const ROW_DY = 90; // vertical gap between blocks within a chain
|
||||
|
||||
export function runtime3ToBlocksDoc(yamlText: string): Runtime3ImportResult {
|
||||
const parsed = load(yamlText);
|
||||
if (!isObject(parsed)) {
|
||||
throw new Error('Runtime 3 : le document YAML doit être un mapping en racine.');
|
||||
}
|
||||
const doc = parsed as Yaml;
|
||||
|
||||
const found = findSteps(doc);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
"Runtime 3 : aucune liste d'étapes reconnue (firmware.steps, steps_runtime_order, steps…).",
|
||||
);
|
||||
}
|
||||
const { steps, sourceKey } = found;
|
||||
const warnings: string[] = [];
|
||||
const sceneCatalog = buildSceneCatalog(doc);
|
||||
|
||||
// Some files keep the narrative scene<->step pairing separately; map step id
|
||||
// → scene id so we can join even when the step lists only `screen_scene_id`.
|
||||
const nodes: BlocksNode[] = [];
|
||||
let stepCount = 0;
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const rawStepId = asString(step['step_id'] ?? step['id'] ?? step['scene_id']).trim();
|
||||
if (!rawStepId) {
|
||||
warnings.push(`step #${index + 1}: no id (step_id/id) — skipped`);
|
||||
return;
|
||||
}
|
||||
const sid = bareId(rawStepId);
|
||||
const colX = index * STEP_DX + 40;
|
||||
let row = 0;
|
||||
const at = (): [number, number] => [colX, 40 + row++ * ROW_DY];
|
||||
|
||||
// Chain head: sceneStart(id).
|
||||
const startNode: BlocksNode = {
|
||||
id: `${sid}__start`,
|
||||
kind: 'sceneStart',
|
||||
position: at(),
|
||||
params: { id: sid },
|
||||
};
|
||||
const chain: BlocksNode[] = [startNode];
|
||||
|
||||
// Scene object: inline `scene` (object), or joined from the catalog via the
|
||||
// step's screen_scene_id / scene (string).
|
||||
const inlineScene = isObject(step['scene']) ? (step['scene'] as Yaml) : undefined;
|
||||
const sceneRef = asString(step['screen_scene_id'] ?? step['scene']).trim();
|
||||
const catalogScene = sceneRef ? sceneCatalog.get(sceneRef) : undefined;
|
||||
const sceneP = sceneParams(inlineScene) ?? sceneParams(catalogScene);
|
||||
if (sceneP) {
|
||||
chain.push({ id: `${sid}__scene`, kind: 'scene', position: at(), params: sceneP });
|
||||
}
|
||||
|
||||
// Puzzle object: inline on the step.
|
||||
if (isObject(step['puzzle'])) {
|
||||
const pNode = puzzleNode(step['puzzle'] as Yaml, sid, warnings, at());
|
||||
if (pNode) chain.push(pNode);
|
||||
}
|
||||
|
||||
// npc / hints / acts / timeboxes / audio packs have no block — report once.
|
||||
for (const skipKey of ['npc', 'hints', 'hint', 'narrative', 'act', 'timebox_minutes', 'audio_pack_id', 'apps']) {
|
||||
if (step[skipKey] !== undefined && step[skipKey] !== '' &&
|
||||
!(Array.isArray(step[skipKey]) && (step[skipKey] as unknown[]).length === 0)) {
|
||||
warnings.push(`${sid}: "${skipKey}" not expressible as a block — skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions → goto / on* blocks.
|
||||
const rawTransitions = Array.isArray(step['transitions']) ? step['transitions'] : [];
|
||||
rawTransitions.forEach((rt, ti) => {
|
||||
const t = parseTransition(rt);
|
||||
if (!t) {
|
||||
warnings.push(`${sid}: transition #${ti + 1} unparseable — skipped`);
|
||||
return;
|
||||
}
|
||||
if (t.eventType && !KNOWN_EVENT_TYPES.has(t.eventType)) {
|
||||
warnings.push(
|
||||
`${sid}: transition event_type "${t.eventType}" unknown — emitted as goto`,
|
||||
);
|
||||
}
|
||||
chain.push(transitionNode(t, `${sid}__t${ti}`, at()));
|
||||
});
|
||||
|
||||
// Chain tail: sceneEnd (a cap block).
|
||||
chain.push({ id: `${sid}__end`, kind: 'sceneEnd', position: at() });
|
||||
|
||||
// Wire `next` pointers down the chain.
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const cur = chain[i];
|
||||
const nxt = chain[i + 1];
|
||||
if (cur && nxt) cur.next = nxt.id;
|
||||
}
|
||||
nodes.push(...chain);
|
||||
stepCount++;
|
||||
});
|
||||
|
||||
if (stepCount === 0) {
|
||||
throw new Error(
|
||||
`Runtime 3 : la liste "${sourceKey}" ne contient aucune étape importable.`,
|
||||
);
|
||||
}
|
||||
|
||||
warnings.unshift(
|
||||
`Importé depuis "${sourceKey}" — ${stepCount} étape(s) converties en chaînes de blocs.`,
|
||||
);
|
||||
|
||||
return { doc: serializeBlocksDoc(nodes), warnings, stepCount };
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { BlocksNode } from '@zacus/shared';
|
||||
|
||||
/**
|
||||
* ScenePlayer — lightweight blocks_studio v2 walker for the 3D stage sim.
|
||||
*
|
||||
* Plays the scene graph the way the firmware's Runtime 3 engine would, at
|
||||
* visualization fidelity: « Zacus dit » surfaces as the NPC bubble (+ Web
|
||||
* Speech), timers wait, gotos/branches jump between scenes, hardware
|
||||
* actions are narrated to the log. This is a *simulation aid*, not the
|
||||
* deploy path — the authoritative compiler stays blocks_to_runtime3.py.
|
||||
*/
|
||||
|
||||
export interface ScenePlayerCallbacks {
|
||||
/** « Zacus dit » — text plus the voice fields carried by the block. */
|
||||
onSay: (
|
||||
text: string,
|
||||
opts: { voice?: string | undefined; emotion?: string | undefined; rate?: number | undefined },
|
||||
) => void;
|
||||
/** One narration line per non-voice action (hardware, ESP-NOW, …). */
|
||||
onLog: (line: string) => void;
|
||||
/** Entered a scene (sceneStart id, or synthetic chain head). */
|
||||
onSceneChange: (sceneId: string) => void;
|
||||
/** Walk finished (sceneEnd, dangling chain or safety cap). */
|
||||
onEnd: (reason: string) => void;
|
||||
}
|
||||
|
||||
const MAX_VISITED_BLOCKS = 500; // runaway-goto safety cap
|
||||
const DEFAULT_WAIT_LOG_MS = 250; // narration pacing for instant actions
|
||||
|
||||
export class ScenePlayer {
|
||||
private byId = new Map<string, BlocksNode>();
|
||||
private sceneHeads = new Map<string, BlocksNode>();
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped = false;
|
||||
private visited = 0;
|
||||
|
||||
constructor(
|
||||
nodes: readonly BlocksNode[],
|
||||
private cb: ScenePlayerCallbacks,
|
||||
) {
|
||||
for (const n of nodes) this.byId.set(n.id, n);
|
||||
for (const n of nodes) {
|
||||
if (n.kind !== 'sceneStart') continue;
|
||||
const sceneId = String(n.params?.['id'] ?? '').trim();
|
||||
if (sceneId && !this.sceneHeads.has(sceneId)) this.sceneHeads.set(sceneId, n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start at the first sceneStart (document order). No-op when none. */
|
||||
play(): void {
|
||||
const entry = [...this.byId.values()].find((n) => n.kind === 'sceneStart');
|
||||
if (!entry) {
|
||||
this.cb.onEnd('aucune scène');
|
||||
return;
|
||||
}
|
||||
this.enter(entry);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
private enter(start: BlocksNode): void {
|
||||
const sceneId = String(start.params?.['id'] ?? '').trim() || start.id;
|
||||
this.cb.onSceneChange(sceneId);
|
||||
this.step(this.next(start));
|
||||
}
|
||||
|
||||
private gotoScene(target: string, from: string): void {
|
||||
const head = this.sceneHeads.get(target.trim());
|
||||
if (!head) {
|
||||
this.cb.onLog(`⚠ ${from} → scène inconnue « ${target} » — arrêt`);
|
||||
this.cb.onEnd('cible inconnue');
|
||||
return;
|
||||
}
|
||||
this.enter(head);
|
||||
}
|
||||
|
||||
private next(node: BlocksNode): BlocksNode | null {
|
||||
return node.next ? (this.byId.get(node.next) ?? null) : null;
|
||||
}
|
||||
|
||||
private after(ms: number, fn: () => void): void {
|
||||
this.timer = setTimeout(() => {
|
||||
if (!this.stopped) fn();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
private step(node: BlocksNode | null): void {
|
||||
if (this.stopped) return;
|
||||
if (!node) {
|
||||
this.cb.onEnd('fin de pile');
|
||||
return;
|
||||
}
|
||||
if (++this.visited > MAX_VISITED_BLOCKS) {
|
||||
this.cb.onLog('⚠ boucle de scènes détectée — arrêt de la simulation');
|
||||
this.cb.onEnd('boucle');
|
||||
return;
|
||||
}
|
||||
|
||||
const p = node.params ?? {};
|
||||
switch (node.kind) {
|
||||
case 'sceneEnd':
|
||||
this.cb.onEnd('⏹ fin de scène');
|
||||
return;
|
||||
case 'sceneStart': // mid-chain start: treat as a new scene head
|
||||
this.enter(node);
|
||||
return;
|
||||
case 'sceneGoto':
|
||||
this.gotoScene(String(p['target'] ?? ''), 'Aller à');
|
||||
return;
|
||||
case 'sceneBranch': {
|
||||
// No expression evaluator in the sim — narrate and take « alors ».
|
||||
this.cb.onLog(`Branche si « ${String(p['condition'] ?? '')} » — sim: alors`);
|
||||
this.gotoScene(String(p['ifTrue'] ?? ''), 'Branche');
|
||||
return;
|
||||
}
|
||||
case 'npcIntentMatch': {
|
||||
this.cb.onLog(`Si intent = « ${String(p['intent'] ?? '')} » — sim: suppose oui`);
|
||||
this.gotoScene(String(p['then'] ?? ''), 'Intent');
|
||||
return;
|
||||
}
|
||||
case 'npcSay': {
|
||||
const text = String(p['text'] ?? '');
|
||||
const rate = Number(p['rate'] ?? 1) || 1;
|
||||
this.cb.onSay(text, {
|
||||
voice: String(p['voice'] ?? '') || undefined,
|
||||
emotion: String(p['emotion'] ?? '') || undefined,
|
||||
rate,
|
||||
});
|
||||
// Rough speaking time: ~15 chars/s adjusted by rate, min 1 s.
|
||||
const ms = Math.max(1000, ((text.length / 15) * 1000) / rate);
|
||||
this.after(ms, () => this.step(this.next(node)));
|
||||
return;
|
||||
}
|
||||
case 'npcWaitResponse': {
|
||||
const s = Number(p['timeout_s'] ?? 10) || 10;
|
||||
this.cb.onLog(`Attente réponse joueur (${s} s) — sim: 1 s`);
|
||||
this.after(1000, () => this.step(this.next(node)));
|
||||
return;
|
||||
}
|
||||
case 'logicTimer': {
|
||||
const s = Number(p['seconds'] ?? 5) || 0;
|
||||
this.cb.onLog(`Attendre ${s} s`);
|
||||
this.after(Math.min(s, 5) * 1000, () => this.step(this.next(node)));
|
||||
return;
|
||||
}
|
||||
case 'logicIf': {
|
||||
// Narrate and walk the « alors » slot inline, then continue.
|
||||
this.cb.onLog(`si « ${String(p['condition'] ?? '')} » — sim: alors`);
|
||||
const bodyHead = node.slots?.['body'] ? this.byId.get(node.slots['body']) : null;
|
||||
if (bodyHead) this.walkSlotChain(bodyHead);
|
||||
this.after(DEFAULT_WAIT_LOG_MS, () => this.step(this.next(node)));
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
// Hardware / ESP-NOW / M5 / PLIP and friends: narrate.
|
||||
const detail = Object.entries(p)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(' ');
|
||||
this.cb.onLog(`[${node.kind}] ${detail}`.trim());
|
||||
this.after(DEFAULT_WAIT_LOG_MS, () => this.step(this.next(node)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrate a logicIf slot chain synchronously (plain actions only). */
|
||||
private walkSlotChain(head: BlocksNode): void {
|
||||
let cur: BlocksNode | null = head;
|
||||
let guard = 0;
|
||||
while (cur && guard++ < 50) {
|
||||
const p: Record<string, string | number> = cur.params ?? {};
|
||||
if (cur.kind === 'npcSay') {
|
||||
this.cb.onSay(String(p['text'] ?? ''), {
|
||||
voice: String(p['voice'] ?? '') || undefined,
|
||||
emotion: String(p['emotion'] ?? '') || undefined,
|
||||
rate: Number(p['rate'] ?? 1) || 1,
|
||||
});
|
||||
} else {
|
||||
this.cb.onLog(` ↳ [${cur.kind}]`);
|
||||
}
|
||||
cur = this.next(cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Faithful mirror of the firmware LCD scene model (P3a of the asset authoring
|
||||
* plan). Values come straight from ESP32_ZACUS display_ui.cpp (ST7796 480×320
|
||||
* landscape, Workbench palette) and the gateway-side scene contract in
|
||||
* tools/scenario/runtime3_common.py::_validate_step_scene. Keep the two in
|
||||
* sync — the char limits and effect enum are enforced at compile time.
|
||||
*/
|
||||
export type ScreenEffect = 'pulse' | 'glitch' | 'gyro' | 'none';
|
||||
|
||||
export const EFFECTS: { value: ScreenEffect; label: string }[] = [
|
||||
{ value: 'pulse', label: 'Pulse (respiration)' },
|
||||
{ value: 'glitch', label: 'Glitch' },
|
||||
{ value: 'gyro', label: 'Gyro (anneau)' },
|
||||
{ value: 'none', label: 'Aucun' },
|
||||
];
|
||||
|
||||
/** Panel geometry, colours and per-field fonts/limits — mirror of display_ui.cpp. */
|
||||
export const SCREEN = {
|
||||
width: 480,
|
||||
height: 320,
|
||||
bg: '#0055AA', // COL_BG — Workbench blue
|
||||
title: { max: 47, color: '#FFFFFF', font: "'Orbitron','Arial Narrow',sans-serif" }, // COL_VALUE / Orbitron 40
|
||||
symbol: { max: 15, color: '#FF8800', font: "'Orbitron','Arial Narrow',sans-serif" }, // COL_CODE / Orbitron 40
|
||||
subtitle: { max: 63, color: '#AAAAAA', font: "'IBM Plex Mono',monospace" }, // COL_LABEL / IBM Plex Mono 18
|
||||
} as const;
|
||||
|
||||
export interface SceneDraft {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
symbol: string;
|
||||
effect: ScreenEffect;
|
||||
}
|
||||
|
||||
export const EMPTY_SCENE: SceneDraft = { title: '', subtitle: '', symbol: '', effect: 'pulse' };
|
||||
|
||||
const FIELD_MAX: Record<'title' | 'subtitle' | 'symbol', number> = {
|
||||
title: SCREEN.title.max,
|
||||
subtitle: SCREEN.subtitle.max,
|
||||
symbol: SCREEN.symbol.max,
|
||||
};
|
||||
|
||||
/** Per-field over-limit check (matches the strict gateway validator). */
|
||||
export function overLimit(field: 'title' | 'subtitle' | 'symbol', value: string): boolean {
|
||||
return value.length > FIELD_MAX[field];
|
||||
}
|
||||
|
||||
function yamlString(value: string): string {
|
||||
// Quote conservatively — scene text routinely carries accents, colons and
|
||||
// apostrophes. Double-quote and escape backslash/quote.
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `scene:` YAML block an author pastes onto a scenario step.
|
||||
* Empty title/subtitle/symbol are omitted; effect is always written.
|
||||
*/
|
||||
export function sceneToYaml(s: SceneDraft, indent = 4): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const inner = ' '.repeat(indent + 2);
|
||||
const lines = [`${pad}scene:`];
|
||||
if (s.title.trim()) lines.push(`${inner}title: ${yamlString(s.title.trim())}`);
|
||||
if (s.subtitle.trim()) lines.push(`${inner}subtitle: ${yamlString(s.subtitle.trim())}`);
|
||||
if (s.symbol.trim()) lines.push(`${inner}symbol: ${yamlString(s.symbol.trim())}`);
|
||||
lines.push(`${inner}effect: ${s.effect}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Client for the gateway's public share endpoints (`POST /v1/share`,
|
||||
* `GET /v1/share/{id}`). These are token-free by design so a player opening a
|
||||
* shared link — who has no bearer token — can still load the scenario.
|
||||
*
|
||||
* Gateway base URL resolves from `VITE_GATEWAY_BASE_URL` (same env as useDeploy),
|
||||
* defaulting to the LAN gateway on :8400.
|
||||
*/
|
||||
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
|
||||
|
||||
function gatewayBaseUrl(): string {
|
||||
// Static `import.meta.env.VITE_*` access so Vite inlines the value at build
|
||||
// time (an aliased / optional-chained read is NOT statically replaced).
|
||||
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
|
||||
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Upload the scenario YAML, returning its short share id. */
|
||||
export async function createShare(yaml: string): Promise<string> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ yaml }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`partage refusé (HTTP ${resp.status})`);
|
||||
}
|
||||
const data = (await resp.json()) as { id: string };
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** Resolve a share id back to its scenario YAML. */
|
||||
export async function fetchShare(id: string): Promise<string> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/share/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`lien introuvable (HTTP ${resp.status})`);
|
||||
}
|
||||
const data = (await resp.json()) as { yaml: string };
|
||||
return data.yaml;
|
||||
}
|
||||
|
||||
/** Build the shareable URL for an id, anchored on the current Atelier path. */
|
||||
export function shareLinkFor(id: string): string {
|
||||
return `${window.location.origin}${window.location.pathname}?s=${id}`;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Client for the gateway's asset staging endpoint (P4/P5): pushes the staged
|
||||
* assets to a board's microSD via POST /v1/assets/stage. Token-protected like
|
||||
* the flash endpoints (VITE_GATEWAY_TOKEN), so it carries the bearer header.
|
||||
*/
|
||||
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
|
||||
|
||||
function gatewayBaseUrl(): string {
|
||||
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
|
||||
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = import.meta.env.VITE_GATEWAY_TOKEN;
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token && token.trim() ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface StageBoard {
|
||||
name: string;
|
||||
label: string;
|
||||
ip: string | null;
|
||||
}
|
||||
|
||||
export interface StageStep {
|
||||
asset: string;
|
||||
status: 'ok' | 'warn' | 'skip' | 'error';
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface StageResult {
|
||||
board: string;
|
||||
strategy_used: string;
|
||||
ok: boolean;
|
||||
base_path: string;
|
||||
steps: StageStep[];
|
||||
manifest: string[];
|
||||
}
|
||||
|
||||
/** Boards declared in the gateway's boards.yaml (name + reachability). */
|
||||
export async function fetchBoards(): Promise<StageBoard[]> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/flash/boards`, { headers: authHeaders() });
|
||||
if (!resp.ok) throw new Error(`liste des cartes indisponible (HTTP ${resp.status})`);
|
||||
const raw = (await resp.json()) as { name: string; label: string; ip: string | null }[];
|
||||
return raw.map((b) => ({ name: b.name, label: b.label, ip: b.ip }));
|
||||
}
|
||||
|
||||
/** Stage the generated assets onto `board`'s SD (hot push or cold host bundle). */
|
||||
export async function stageAssets(
|
||||
board: string,
|
||||
opts: { keys?: string[]; strategy?: 'auto' | 'hot' | 'cold' } = {},
|
||||
): Promise<StageResult> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/stage`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ board, keys: opts.keys ?? [], strategy: opts.strategy ?? 'auto' }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`staging refusé (HTTP ${resp.status})${detail ? ` — ${detail.slice(0, 140)}` : ''}`);
|
||||
}
|
||||
return (await resp.json()) as StageResult;
|
||||
}
|
||||
|
||||
/** Response of the D2 "arm a step" endpoint (POST /v1/step/{board}). */
|
||||
export interface ArmStepResult {
|
||||
board: string;
|
||||
route: string;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
armed: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** Response of the D3 "play a media cue" endpoint (POST /v1/media/play). */
|
||||
export interface PlayMediaResult {
|
||||
board: string;
|
||||
route: string;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** D2 — arm a runtime step on `board` (gateway forwards to the live hardware). */
|
||||
export async function armStep(board: string, stepId: string): Promise<ArmStepResult> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/step/${encodeURIComponent(board)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`armement refusé (HTTP ${resp.status})${detail ? ` — ${detail.slice(0, 140)}` : ''}`);
|
||||
}
|
||||
return (await resp.json()) as ArmStepResult;
|
||||
}
|
||||
|
||||
/** D3 — play a media cue on `board`; the gateway resolves `path` to an absolute /sdcard path. */
|
||||
export async function playMedia(board: string, path: string): Promise<PlayMediaResult> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/media/play`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ board, path }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`lecture refusée (HTTP ${resp.status})${detail ? ` — ${detail.slice(0, 140)}` : ''}`);
|
||||
}
|
||||
return (await resp.json()) as PlayMediaResult;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Client for the gateway's text-asset generation endpoint
|
||||
* (`POST /v1/assets/text/generate`), which proxies the ailiance LLM gateway.
|
||||
* Token-free, same-origin in prod (zacus.saillant.cc/api). P1 of the asset
|
||||
* authoring plan: generate in-voice NPC lines, hints and screen text.
|
||||
*/
|
||||
const GATEWAY_DEFAULT_BASE_URL = 'http://localhost:8400';
|
||||
|
||||
function gatewayBaseUrl(): string {
|
||||
const configured = import.meta.env.VITE_GATEWAY_BASE_URL;
|
||||
const base = configured && configured.trim() ? configured : GATEWAY_DEFAULT_BASE_URL;
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export type TextGenKind = 'npc_reply' | 'hint' | 'scene_text' | 'freeform';
|
||||
|
||||
export interface TextGenResult {
|
||||
kind: string;
|
||||
text: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Generate a text asset of `kind` from the author `prompt`. */
|
||||
export async function generateText(kind: TextGenKind, prompt: string): Promise<TextGenResult> {
|
||||
const resp = await fetch(`${gatewayBaseUrl()}/v1/assets/text/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind, prompt }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`génération refusée (HTTP ${resp.status})${detail ? ` — ${detail.slice(0, 120)}` : ''}`);
|
||||
}
|
||||
return (await resp.json()) as TextGenResult;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect } from 'react';
|
||||
import type * as Blockly from 'blockly';
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore.js';
|
||||
|
||||
/**
|
||||
* On boot, if the URL carries `?s=<id>`, fetch the shared scenario from the
|
||||
* gateway and import it into the Blockly workspace as soon as it mounts. The
|
||||
* `?s=` param is then dropped from the address bar so a later reload (or the
|
||||
* user's own edits) don't get clobbered by a re-import.
|
||||
*/
|
||||
export function useShareLink(): void {
|
||||
useEffect(() => {
|
||||
const id = new URLSearchParams(window.location.search).get('s');
|
||||
if (!id) return;
|
||||
|
||||
let cancelled = false;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const importInto = async (ws: Blockly.WorkspaceSvg, yaml: string) => {
|
||||
const { importYamlToWorkspace } = await import('./yaml-import.js');
|
||||
importYamlToWorkspace(ws, yaml);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('s');
|
||||
window.history.replaceState({}, '', url);
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
let yaml: string;
|
||||
try {
|
||||
const { fetchShare } = await import('./share.js');
|
||||
yaml = await fetchShare(id);
|
||||
} catch (err) {
|
||||
console.error('Atelier: échec du chargement du lien partagé', err);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const current = useWorkspaceStore.getState().workspace;
|
||||
if (current) {
|
||||
void importInto(current, yaml);
|
||||
return;
|
||||
}
|
||||
// Workspace not mounted yet — import once it appears.
|
||||
unsubscribe = useWorkspaceStore.subscribe((state) => {
|
||||
if (state.workspace && !cancelled) {
|
||||
unsubscribe?.();
|
||||
void importInto(state.workspace, yaml);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ScenarioYaml } from '@zacus/shared';
|
||||
import type { BlocksNode } from '@zacus/shared';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
@@ -8,76 +8,83 @@ export interface ValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed ScenarioYaml against 6 rules derived from the Zacus V3 spec.
|
||||
* Returns a ValidationResult with errors, warnings, and a human-readable summary.
|
||||
* Validate blocks_studio v2 nodes against the same rules the gateway
|
||||
* compiler (blocks_to_runtime3.py) enforces or warns about — surfaced in
|
||||
* the editor before the user ever hits « Déployer ».
|
||||
*/
|
||||
export function validateScenario(scenario: ScenarioYaml): ValidationResult {
|
||||
export function validateBlocksNodes(nodes: readonly BlocksNode[]): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Rule 1: All puzzle code_digits must sum to exactly 8 total digits
|
||||
const totalDigits = scenario.puzzles.reduce((sum, p) => {
|
||||
const count = (p.code_digits?.length ?? 0) + (p.code_digit != null ? 1 : 0);
|
||||
return sum + count;
|
||||
}, 0);
|
||||
if (totalDigits !== 8) {
|
||||
errors.push(`Les digits de code totalisent ${totalDigits} chiffre(s) — attendu 8`);
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
valid: true,
|
||||
errors,
|
||||
warnings: ['Espace de travail vide'],
|
||||
summary: 'Espace de travail vide',
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 2: Every puzzle must be referenced in at least one phase
|
||||
const referencedPuzzles = new Set(
|
||||
scenario.phases.flatMap((ph) => ph.puzzles.map((p) => p.puzzle_ref)),
|
||||
);
|
||||
for (const puzzle of scenario.puzzles) {
|
||||
if (!referencedPuzzles.has(puzzle.id)) {
|
||||
errors.push(`Puzzle ${puzzle.id} n'est référencé dans aucune phase`);
|
||||
// Scene inventory.
|
||||
const sceneStarts = nodes.filter((n) => n.kind === 'sceneStart');
|
||||
const sceneIds = new Set<string>();
|
||||
for (const s of sceneStarts) {
|
||||
const id = String(s.params?.['id'] ?? '').trim();
|
||||
if (!id) {
|
||||
warnings.push('Un « ▶ Début de scène » a un id vide (step anonyme généré)');
|
||||
continue;
|
||||
}
|
||||
if (sceneIds.has(id)) errors.push(`Id de scène dupliqué : « ${id} »`);
|
||||
sceneIds.add(id);
|
||||
}
|
||||
|
||||
// Rule 3: PROFILING phase must have exactly 2 puzzles (one TECH, one BOTH)
|
||||
const profilingPhase = scenario.phases.find((ph) => ph.type === 'PROFILING');
|
||||
if (!profilingPhase) {
|
||||
errors.push('Aucune phase PROFILING définie');
|
||||
} else {
|
||||
if (profilingPhase.puzzles.length !== 2) {
|
||||
errors.push(
|
||||
`Phase PROFILING: ${profilingPhase.puzzles.length} puzzle(s) — attendu 2`,
|
||||
// Rule 1: at least one entry point.
|
||||
if (sceneStarts.length === 0) {
|
||||
errors.push("Aucun bloc « ▶ Début de scène » — le scénario n'a pas de point d'entrée");
|
||||
}
|
||||
|
||||
// Rule 2: every root chain should start with a sceneStart (the compiler
|
||||
// emits a synthetic step otherwise).
|
||||
const referenced = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
if (n.next) referenced.add(n.next);
|
||||
for (const head of Object.values(n.slots ?? {})) referenced.add(head);
|
||||
}
|
||||
for (const n of nodes) {
|
||||
if (referenced.has(n.id)) continue; // not a root
|
||||
if (n.kind !== 'sceneStart') {
|
||||
warnings.push(
|
||||
`La pile commençant par « ${n.kind} » ne démarre pas par un Début de scène (step synthétique)`,
|
||||
);
|
||||
}
|
||||
const filters = profilingPhase.puzzles.map((p) => p.profile_filter);
|
||||
if (!filters.includes('TECH')) warnings.push('Phase PROFILING: aucun puzzle TECH');
|
||||
if (!filters.includes('BOTH')) warnings.push('Phase PROFILING: aucun puzzle BOTH');
|
||||
}
|
||||
|
||||
// Rule 4: CLIMAX phase must exist and contain P7_COFFRE
|
||||
const climaxPhase = scenario.phases.find((ph) => ph.type === 'CLIMAX');
|
||||
if (!climaxPhase) {
|
||||
errors.push('Aucune phase CLIMAX définie');
|
||||
} else {
|
||||
const hasCoffre = climaxPhase.puzzles.some((p) => p.puzzle_ref === 'P7_COFFRE');
|
||||
if (!hasCoffre) {
|
||||
errors.push('Phase CLIMAX: P7_COFFRE (Coffre Final) absent');
|
||||
// Rule 3: navigation targets should reference known scene ids.
|
||||
const checkTarget = (kind: string, value: unknown) => {
|
||||
const target = String(value ?? '').trim();
|
||||
if (target && sceneIds.size > 0 && !sceneIds.has(target)) {
|
||||
warnings.push(`« ${kind} » pointe vers une scène inconnue : « ${target} »`);
|
||||
}
|
||||
};
|
||||
for (const n of nodes) {
|
||||
if (n.kind === 'sceneGoto') checkTarget('Aller à', n.params?.['target']);
|
||||
if (n.kind === 'sceneBranch') {
|
||||
checkTarget('Branche (alors)', n.params?.['ifTrue']);
|
||||
checkTarget('Branche (sinon)', n.params?.['ifFalse']);
|
||||
}
|
||||
if (n.kind === 'npcIntentMatch') checkTarget('Si intent', n.params?.['then']);
|
||||
}
|
||||
|
||||
// Rule 5: Duration target must be configured
|
||||
if (!scenario.duration.target_minutes || scenario.duration.target_minutes <= 0) {
|
||||
errors.push('Durée cible non configurée ou invalide');
|
||||
}
|
||||
|
||||
// Rule 6: Scenario must have at least 3 phases (PROFILING, ADAPTIVE, CLIMAX)
|
||||
const requiredPhases = ['PROFILING', 'ADAPTIVE', 'CLIMAX'] as const;
|
||||
for (const required of requiredPhases) {
|
||||
if (!scenario.phases.some((ph) => ph.type === required)) {
|
||||
errors.push(`Phase ${required} manquante`);
|
||||
// Rule 4: « Zacus dit » should not be empty (TTS of nothing).
|
||||
for (const n of nodes) {
|
||||
if (n.kind === 'npcSay' && !String(n.params?.['text'] ?? '').trim()) {
|
||||
warnings.push('Un « Zacus dit » est vide — la voix ne dira rien');
|
||||
}
|
||||
}
|
||||
|
||||
const valid = errors.length === 0;
|
||||
const puzzleCount = scenario.puzzles.length;
|
||||
const durationMin = scenario.duration.target_minutes;
|
||||
const summary = valid
|
||||
? `Scénario valide — ${puzzleCount} puzzles, ~${durationMin} min`
|
||||
? `Scénario valide — ${sceneStarts.length} scène(s), ${nodes.length} bloc(s)`
|
||||
: `${errors.length} erreur(s) à corriger`;
|
||||
|
||||
return { valid, errors, warnings, summary };
|
||||
|
||||
@@ -1,42 +1,114 @@
|
||||
import * as Blockly from 'blockly/core';
|
||||
import { javascriptGenerator } from 'blockly/javascript';
|
||||
import { parseScenarioYaml } from '@zacus/shared';
|
||||
import type * as Blockly from 'blockly/core';
|
||||
import { serializeBlocksDoc, serializeReporter, type BlocksNode } from '@zacus/shared';
|
||||
import { ZACUS_FIELDS_BY_KIND } from '@zacus/shared/blockly';
|
||||
|
||||
const PREFIX = 'zacus_';
|
||||
|
||||
// Reporter blocks have an output and no statement connections. They carry the
|
||||
// value-input operands that compile to an expression string. Statement blocks
|
||||
// that accept a branched condition reporter (logicIf/loopWhile/sceneBranch)
|
||||
// expose a "condition" value input alongside a text field of repli.
|
||||
const REPORTER_KINDS = new Set([
|
||||
'opCompare', 'opBool', 'opNot', 'opArith',
|
||||
'valVar', 'valScore', 'valNumber', 'valText',
|
||||
]);
|
||||
|
||||
// For each block type, the value inputs whose branched reporter we snapshot
|
||||
// into node.inputs (so the gateway can resolve them to an expression string).
|
||||
const VALUE_INPUTS_BY_TYPE: Record<string, readonly string[]> = {
|
||||
zacus_opCompare: ['A', 'B'],
|
||||
zacus_opBool: ['A', 'B'],
|
||||
zacus_opNot: ['A'],
|
||||
zacus_opArith: ['A', 'B'],
|
||||
zacus_logicIf: ['condition'],
|
||||
zacus_loopWhile: ['condition'],
|
||||
zacus_sceneBranch: ['condition'],
|
||||
// Vague 2 — règles game-master : condition branchée (reporter) ou texte.
|
||||
zacus_gmRule: ['condition'],
|
||||
zacus_scoreBonus: ['condition'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Export the current Blockly workspace to a zacus_v3_complete.yaml string.
|
||||
* Runs all block generators and wraps the output in a minimal YAML skeleton,
|
||||
* then validates by parsing. Throws if invalid.
|
||||
* Snapshot the workspace as flat blocks_studio v2 nodes: one node per
|
||||
* zacus_* block, `next` pointers from the stack connections, `slots` from
|
||||
* the statement inputs (logicIf "alors"/"sinon", loops "faire"), `inputs`
|
||||
* from value inputs (reporter operands). Kinds drop the zacus_ prefix to
|
||||
* match the gateway compiler and the hub Studio.
|
||||
*
|
||||
* Backward-compat: for blocks with a branched condition reporter, we ALSO
|
||||
* resolve it into the `condition` text param so consumers reading only the
|
||||
* string (legacy firmware, scenario engine) keep working. If no reporter is
|
||||
* branched, the typed text field is preserved untouched.
|
||||
*/
|
||||
export function exportWorkspaceToYaml(workspace: Blockly.Workspace): string {
|
||||
const fragments = javascriptGenerator.workspaceToCode(workspace);
|
||||
const skeleton = buildYamlSkeleton(fragments);
|
||||
// Validate by parsing (throws if schema is invalid)
|
||||
parseScenarioYaml(skeleton);
|
||||
return skeleton;
|
||||
export function workspaceToBlocksNodes(workspace: Blockly.Workspace): BlocksNode[] {
|
||||
const nodes: BlocksNode[] = [];
|
||||
const all = workspace.getAllBlocks(false).filter(
|
||||
(b) => b.type.startsWith(PREFIX) && !b.isInsertionMarker(),
|
||||
);
|
||||
|
||||
for (const block of all) {
|
||||
const kind = block.type.slice(PREFIX.length);
|
||||
const xy = block.getRelativeToSurfaceXY();
|
||||
const node: BlocksNode = {
|
||||
id: block.id,
|
||||
kind,
|
||||
position: [xy.x, xy.y],
|
||||
};
|
||||
|
||||
// Reporters are wired through value inputs, never through next/previous.
|
||||
if (!REPORTER_KINDS.has(kind)) {
|
||||
const next = block.getNextBlock();
|
||||
if (next) node.next = next.id;
|
||||
}
|
||||
|
||||
const fieldNames = ZACUS_FIELDS_BY_KIND[block.type] ?? [];
|
||||
const params: Record<string, string | number> = {};
|
||||
for (const name of fieldNames) {
|
||||
const value = block.getFieldValue(name) as string | number | null;
|
||||
if (value !== null && value !== undefined) params[name] = value;
|
||||
}
|
||||
|
||||
// Statement inputs (c-block bodies / else / loop bodies).
|
||||
const slots: Record<string, string> = {};
|
||||
for (const slotName of ['body', 'else']) {
|
||||
const head = block.getInputTargetBlock(slotName);
|
||||
if (head) slots[slotName] = head.id;
|
||||
}
|
||||
if (Object.keys(slots).length > 0) node.slots = slots;
|
||||
|
||||
// Value inputs (reporter operands + branched conditions).
|
||||
const inputs: Record<string, string> = {};
|
||||
for (const inName of VALUE_INPUTS_BY_TYPE[block.type] ?? []) {
|
||||
const child = block.getInputTargetBlock(inName);
|
||||
if (child) inputs[inName] = child.id;
|
||||
}
|
||||
if (Object.keys(inputs).length > 0) node.inputs = inputs;
|
||||
|
||||
if (Object.keys(params).length > 0) node.params = params;
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// Resolve branched condition reporters into the condition text param so the
|
||||
// gateway and string-only consumers always see a usable expression string.
|
||||
const byId: Record<string, BlocksNode> = {};
|
||||
for (const n of nodes) byId[n.id] = n;
|
||||
for (const n of nodes) {
|
||||
const condChild = n.inputs?.['condition'];
|
||||
if (!condChild) continue;
|
||||
const expr = serializeReporter(condChild, byId);
|
||||
if (!expr) continue;
|
||||
const paramKey = n.kind === 'loopWhile' ? 'condition_text' : 'condition';
|
||||
n.params = { ...(n.params ?? {}), [paramKey]: expr };
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function buildYamlSkeleton(generatedFragments: string): string {
|
||||
return `id: zacus_v3_complete
|
||||
version: "3.1"
|
||||
title: "Le Mystère du Professeur Zacus — V3 Complete"
|
||||
duration:
|
||||
target_minutes: 60
|
||||
min_minutes: 30
|
||||
max_minutes: 90
|
||||
profiling_minutes: 10
|
||||
climax_minutes: 10
|
||||
outro_minutes: 5
|
||||
hardware:
|
||||
puzzles: []
|
||||
scoring:
|
||||
base_score: 1000
|
||||
time_penalty_per_minute: 10
|
||||
hint_penalty: 50
|
||||
bonus_fast_completion: 200
|
||||
npc:
|
||||
adaptive_rules: []
|
||||
phases: []
|
||||
puzzles:
|
||||
${generatedFragments}
|
||||
`;
|
||||
/**
|
||||
* Export the current workspace to a blocks_studio_version: 2 YAML string —
|
||||
* the format the gateway compiles to Runtime 3 IR (POST /v1/flash) and the
|
||||
* zacus-hub native Studio reads/writes.
|
||||
*/
|
||||
export function exportWorkspaceToYaml(workspace: Blockly.Workspace): string {
|
||||
return serializeBlocksDoc(workspaceToBlocksNodes(workspace));
|
||||
}
|
||||
|
||||
@@ -1,121 +1,113 @@
|
||||
import * as Blockly from 'blockly/core';
|
||||
import { parseScenarioYaml } from '@zacus/shared';
|
||||
import type { PuzzleConfig } from '@zacus/shared';
|
||||
import { parseBlocksDocYaml } from '@zacus/shared';
|
||||
import { load } from 'js-yaml';
|
||||
import { runtime3ToBlocksDoc } from './runtime3-import';
|
||||
|
||||
const PREFIX = 'zacus_';
|
||||
|
||||
/**
|
||||
* Import a zacus_v3_complete.yaml string into the Blockly workspace.
|
||||
* Clears the existing workspace and recreates all blocks from the scenario.
|
||||
* Detect whether a YAML document is already in the native
|
||||
* `blocks_studio_version: 2` format. Anything else (a canonical human-authored
|
||||
* Runtime 3 scenario) is routed through runtime3ToBlocksDoc() first.
|
||||
*/
|
||||
export function importYamlToWorkspace(workspace: Blockly.Workspace, yaml: string): void {
|
||||
const scenario = parseScenarioYaml(yaml);
|
||||
function isBlocksStudioV2(yaml: string): boolean {
|
||||
try {
|
||||
const doc = load(yaml) as Record<string, unknown> | null;
|
||||
return !!doc && typeof doc === 'object' && doc['blocks_studio_version'] === 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface YamlImportResult {
|
||||
/** Blocks created in the workspace. */
|
||||
created: number;
|
||||
/** "blocks" when the source was already v2, "runtime3" when converted. */
|
||||
source: 'blocks' | 'runtime3';
|
||||
/** Best-effort conversion notes (empty for native v2 documents). */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import any supported scenario YAML into the workspace, auto-detecting the
|
||||
* format. Native `blocks_studio_version: 2` documents go straight through;
|
||||
* canonical human Runtime 3 scenarios are converted best-effort first, and the
|
||||
* conversion warnings are surfaced to the caller.
|
||||
*/
|
||||
export function importScenarioToWorkspace(
|
||||
workspace: Blockly.Workspace,
|
||||
yaml: string,
|
||||
): YamlImportResult {
|
||||
if (isBlocksStudioV2(yaml)) {
|
||||
return { created: importYamlToWorkspace(workspace, yaml), source: 'blocks', warnings: [] };
|
||||
}
|
||||
const { doc, warnings } = runtime3ToBlocksDoc(yaml);
|
||||
return { created: importYamlToWorkspace(workspace, doc), source: 'runtime3', warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a blocks_studio_version: 2 YAML document (Atelier export or
|
||||
* zacus-hub Studio save) into the workspace. Clears existing content,
|
||||
* recreates every known zacus_* block, restores fields, stack (`next`)
|
||||
* and logicIf slot connections, then positions root blocks.
|
||||
*
|
||||
* Returns the number of blocks created; unknown kinds are skipped.
|
||||
*/
|
||||
export function importYamlToWorkspace(workspace: Blockly.Workspace, yaml: string): number {
|
||||
const doc = parseBlocksDocYaml(yaml);
|
||||
workspace.clear();
|
||||
|
||||
let yOffset = 20;
|
||||
const created = new Map<string, Blockly.Block>();
|
||||
|
||||
// Create phase_container blocks
|
||||
for (const phase of scenario.phases) {
|
||||
const phaseBlock = workspace.newBlock('phase_container') as Blockly.Block;
|
||||
phaseBlock.setFieldValue(phase.type, 'PHASE_TYPE');
|
||||
initBlock(phaseBlock, 20, yOffset);
|
||||
yOffset += 120;
|
||||
|
||||
// Nested puzzle_selector blocks
|
||||
let prevBlock: Blockly.Block | null = null;
|
||||
for (const p of phase.puzzles) {
|
||||
const selBlock = workspace.newBlock('puzzle_selector') as Blockly.Block;
|
||||
selBlock.setFieldValue(p.puzzle_ref, 'PUZZLE_REF');
|
||||
selBlock.setFieldValue(p.profile_filter, 'PROFILE_FILTER');
|
||||
initBlock(selBlock, 0, 0);
|
||||
if (prevBlock === null) {
|
||||
phaseBlock.getInput('PUZZLES')?.connection?.connect(selBlock.previousConnection!);
|
||||
} else {
|
||||
prevBlock.nextConnection?.connect(selBlock.previousConnection!);
|
||||
// Pass 1 — create blocks and restore fields.
|
||||
for (const node of doc.nodes) {
|
||||
const type = PREFIX + node.kind;
|
||||
if (!(type in Blockly.Blocks)) continue; // unknown kind — skip
|
||||
const block = workspace.newBlock(type, node.id);
|
||||
for (const [name, value] of Object.entries(node.params ?? {})) {
|
||||
try {
|
||||
block.setFieldValue(String(value), name);
|
||||
} catch {
|
||||
// field renamed/removed in a newer catalog — ignore, keep default
|
||||
}
|
||||
}
|
||||
created.set(node.id, block);
|
||||
}
|
||||
|
||||
// Pass 2 — connections (stacks then slots).
|
||||
for (const node of doc.nodes) {
|
||||
const block = created.get(node.id);
|
||||
if (!block) continue;
|
||||
if (node.next) {
|
||||
const target = created.get(node.next);
|
||||
if (target && block.nextConnection && target.previousConnection) {
|
||||
block.nextConnection.connect(target.previousConnection);
|
||||
}
|
||||
}
|
||||
for (const [slot, headId] of Object.entries(node.slots ?? {})) {
|
||||
const head = created.get(headId);
|
||||
const input = block.getInput(slot);
|
||||
if (head && input?.connection && head.previousConnection) {
|
||||
input.connection.connect(head.previousConnection);
|
||||
}
|
||||
prevBlock = selBlock;
|
||||
}
|
||||
}
|
||||
|
||||
// Create puzzle config blocks in a second column
|
||||
let xOffset = 420;
|
||||
yOffset = 20;
|
||||
for (const puzzle of scenario.puzzles) {
|
||||
const block = createPuzzleBlock(workspace, puzzle);
|
||||
if (block) {
|
||||
initBlock(block, xOffset, yOffset);
|
||||
yOffset += 80;
|
||||
// Pass 3 — render and position roots (children follow their parent).
|
||||
for (const node of doc.nodes) {
|
||||
const block = created.get(node.id);
|
||||
if (!block) continue;
|
||||
const svg = block as Blockly.BlockSvg;
|
||||
if (typeof svg.initSvg === 'function') svg.initSvg();
|
||||
if (typeof svg.render === 'function') svg.render();
|
||||
if (!block.getParent()) {
|
||||
const [x, y] = node.position;
|
||||
if (x !== 0 || y !== 0) svg.moveBy(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspace instanceof Blockly.WorkspaceSvg) {
|
||||
Blockly.svgResize(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
function initBlock(block: Blockly.Block, x: number, y: number): void {
|
||||
if (typeof (block as Blockly.BlockSvg).initSvg === 'function') {
|
||||
(block as Blockly.BlockSvg).initSvg();
|
||||
}
|
||||
if (typeof (block as Blockly.BlockSvg).render === 'function') {
|
||||
(block as Blockly.BlockSvg).render();
|
||||
}
|
||||
if (x !== 0 || y !== 0) {
|
||||
(block as Blockly.BlockSvg).moveBy(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
const PUZZLE_TYPE_MAP: Record<string, string> = {
|
||||
P1_SON: 'puzzle_sequence_sonore',
|
||||
P2_CIRCUIT: 'puzzle_circuit_led',
|
||||
P3_QR: 'puzzle_qr_treasure',
|
||||
P4_RADIO: 'puzzle_radio',
|
||||
P5_MORSE: 'puzzle_morse',
|
||||
P6_SYMBOLES: 'puzzle_symboles_nfc',
|
||||
P7_COFFRE: 'puzzle_coffre_final',
|
||||
};
|
||||
|
||||
function createPuzzleBlock(workspace: Blockly.Workspace, puzzle: PuzzleConfig): Blockly.Block | null {
|
||||
const blockType = PUZZLE_TYPE_MAP[puzzle.id];
|
||||
if (!blockType) return null;
|
||||
|
||||
const block = workspace.newBlock(blockType) as Blockly.Block;
|
||||
|
||||
switch (puzzle.id) {
|
||||
case 'P1_SON':
|
||||
block.setFieldValue(puzzle.melody_notes?.join(',') ?? '', 'MELODY_NOTES');
|
||||
block.setFieldValue(String(puzzle.difficulty ?? 1), 'DIFFICULTY');
|
||||
block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
|
||||
break;
|
||||
case 'P2_CIRCUIT':
|
||||
block.setFieldValue(puzzle.components?.join(',') ?? '', 'COMPONENTS');
|
||||
block.setFieldValue(puzzle.valid_circuit ?? '', 'VALID_CIRCUIT');
|
||||
block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
|
||||
break;
|
||||
case 'P3_QR':
|
||||
block.setFieldValue(puzzle.qr_codes?.join(',') ?? '', 'QR_CODES');
|
||||
block.setFieldValue(puzzle.correct_order?.join(',') ?? '', 'CORRECT_ORDER');
|
||||
block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
|
||||
break;
|
||||
case 'P4_RADIO':
|
||||
block.setFieldValue(String(puzzle.target_freq ?? 93.5), 'TARGET_FREQ');
|
||||
block.setFieldValue(String(puzzle.range_min ?? 93.0), 'RANGE_MIN');
|
||||
block.setFieldValue(String(puzzle.range_max ?? 94.0), 'RANGE_MAX');
|
||||
block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
|
||||
break;
|
||||
case 'P5_MORSE':
|
||||
block.setFieldValue(puzzle.message ?? 'SOS', 'MESSAGE');
|
||||
block.setFieldValue(puzzle.mode ?? 'tech', 'MODE');
|
||||
block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
|
||||
break;
|
||||
case 'P6_SYMBOLES':
|
||||
block.setFieldValue(puzzle.symbols?.join(',') ?? '', 'SYMBOLS');
|
||||
block.setFieldValue(puzzle.correct_order?.join(',') ?? '', 'CORRECT_ORDER');
|
||||
block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
|
||||
break;
|
||||
case 'P7_COFFRE':
|
||||
block.setFieldValue(String(puzzle.code_length ?? 8), 'CODE_LENGTH');
|
||||
block.setFieldValue(String(puzzle.max_attempts ?? 3), 'MAX_ATTEMPTS');
|
||||
break;
|
||||
}
|
||||
|
||||
return block;
|
||||
return created.size;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
@import '@fontsource/orbitron/400.css';
|
||||
@import '@fontsource/orbitron/700.css';
|
||||
@import '@fontsource/ibm-plex-mono/400.css';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -21,19 +25,159 @@ body {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.atelier-shell {
|
||||
.atelier-root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.atelier-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0a1722;
|
||||
color: #e8eef3;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.atelier-shell::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.05;
|
||||
background-image: linear-gradient(#FF8800 1px, transparent 1px),
|
||||
linear-gradient(90deg, #FF8800 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.atelier-menubar {
|
||||
flex: 0 0 auto;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 14px;
|
||||
background: #15151a;
|
||||
border-bottom: 1px solid #2c2c2e;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.atelier-menubar__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.atelier-menubar__group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.atelier-menubar__btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #2c2c2e;
|
||||
border-radius: 5px;
|
||||
background: #232327;
|
||||
color: #e8e8e8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.atelier-menubar__btn:hover:not(:disabled) {
|
||||
background: #3c3c4a;
|
||||
border-color: #4d4d52;
|
||||
}
|
||||
|
||||
.atelier-menubar__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.atelier-menubar__notice {
|
||||
font-size: 12px;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.atelier-menubar__dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.atelier-menubar__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.atelier-menubar__list {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 61;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
min-width: 280px;
|
||||
background: #1f1f22;
|
||||
border: 1px solid #3c3c4a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.atelier-menubar__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #e8e8e8;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.atelier-menubar__item:hover {
|
||||
background: #3c3c4a;
|
||||
}
|
||||
|
||||
.atelier-menubar__item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.atelier-menubar__item-desc {
|
||||
font-size: 11px;
|
||||
color: #9a9a9d;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Anchored popover hosting a full panel body (game config / deploy / assets). */
|
||||
.atelier-menubar__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 61;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.atelier-menubar__btn--active {
|
||||
background: #3c3c4a;
|
||||
border-color: #4d4d52;
|
||||
}
|
||||
|
||||
.atelier-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #232327;
|
||||
border: 1px solid #2c2c2e;
|
||||
background: #0f2233;
|
||||
border: 1px solid #1c3b52;
|
||||
color: #9a9a9d;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
@@ -61,14 +205,14 @@ body {
|
||||
}
|
||||
|
||||
.atelier-resizer {
|
||||
background: #0e0e10;
|
||||
background: #1c3b52;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.atelier-resizer:hover,
|
||||
.atelier-resizer[data-resize-handle-state='hover'],
|
||||
.atelier-resizer[data-resize-handle-state='drag'] {
|
||||
background: #4d4d52;
|
||||
background: #FF8800;
|
||||
}
|
||||
|
||||
.atelier-resizer--vertical {
|
||||
@@ -81,23 +225,19 @@ body {
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.atelier-mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
/* ── P3a: LCD scene preview effect animations (mirror display_ui.cpp) ── */
|
||||
@keyframes zacus-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.atelier-mode-tab {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #2c2c2e;
|
||||
border-radius: 4px;
|
||||
background: #232327;
|
||||
color: #9a9a9d;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@keyframes zacus-glitch {
|
||||
0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
|
||||
20% { transform: translate(-2px, 1px); clip-path: inset(20% 0 30% 0); }
|
||||
40% { transform: translate(2px, -1px); clip-path: inset(50% 0 10% 0); }
|
||||
60% { transform: translate(-1px, 0); clip-path: inset(10% 0 60% 0); }
|
||||
80% { transform: translate(1px, 1px); clip-path: inset(40% 0 20% 0); }
|
||||
}
|
||||
|
||||
.atelier-mode-tab--active {
|
||||
background: #3c3c4a;
|
||||
color: #fff;
|
||||
border-color: #4d4d52;
|
||||
@keyframes zacus-gyro-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
// Cinematic camera waypoints (position + target)
|
||||
const WAYPOINTS: Array<{ pos: [number, number, number]; target: [number, number, number]; duration: number }> = [
|
||||
{ pos: [0, 3, 6], target: [0, 0, 0], duration: 5000 },
|
||||
{ pos: [-4, 2, -2], target: [-4, 0, -2], duration: 4000 },
|
||||
{ pos: [-4, 2, 2], target: [-4, 0, 2], duration: 4000 },
|
||||
{ pos: [0, 2, -3.5], target: [0, 0, -3.5], duration: 4000 },
|
||||
{ pos: [4, 2, -2], target: [4, 0, -2], duration: 4000 },
|
||||
{ pos: [4, 2, 2], target: [4, 0, 2], duration: 4000 },
|
||||
{ pos: [0, 4, 0], target: [0, 0, 0], duration: 6000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* DemoMode: auto-play with cinematic camera moving between puzzle stations.
|
||||
* Mount inside a Canvas as a child component (uses useFrame + useThree).
|
||||
*/
|
||||
export function DemoCameraController() {
|
||||
const { camera } = useThree();
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
|
||||
let waypointIndex = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (mode !== 'demo') return;
|
||||
elapsed += delta * 1000;
|
||||
const wp = WAYPOINTS[waypointIndex % WAYPOINTS.length]!;
|
||||
|
||||
if (elapsed >= wp.duration) {
|
||||
elapsed = 0;
|
||||
waypointIndex = (waypointIndex + 1) % WAYPOINTS.length;
|
||||
}
|
||||
|
||||
const t = Math.min(elapsed / wp.duration, 1);
|
||||
const smoothT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // ease in-out
|
||||
|
||||
const targetPos = new THREE.Vector3(...wp.pos);
|
||||
camera.position.lerp(targetPos, smoothT * delta * 2);
|
||||
|
||||
const lookTarget = new THREE.Vector3(...wp.target);
|
||||
camera.lookAt(lookTarget);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DemoMode component: auto-solves puzzles at intervals for showcase.
|
||||
*/
|
||||
export function DemoMode() {
|
||||
const { solvePuzzle, solvedPuzzles } = useSimStore();
|
||||
const PUZZLE_SEQUENCE = ['P1_SON', 'P2_CIRCUIT', 'P3_QR', 'P4_RADIO', 'P5_MORSE', 'P6_SYMBOLES', 'P7_COFFRE'];
|
||||
|
||||
useEffect(() => {
|
||||
if (solvedPuzzles.length >= PUZZLE_SEQUENCE.length) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const nextPuzzle = PUZZLE_SEQUENCE[solvedPuzzles.length];
|
||||
if (nextPuzzle && !solvedPuzzles.includes(nextPuzzle)) {
|
||||
solvePuzzle(nextPuzzle);
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [solvedPuzzles.length, solvePuzzle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const HUD_STYLE: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
};
|
||||
|
||||
/**
|
||||
* SandboxMode: free orbit camera + click-to-interact.
|
||||
* OrbitControls live in RoomScene when mode === 'sandbox'.
|
||||
* This component renders the HUD legend.
|
||||
*/
|
||||
export function SandboxMode() {
|
||||
return (
|
||||
<div style={HUD_STYLE}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, color: '#0071e3' }}>
|
||||
Mode Sandbox
|
||||
</div>
|
||||
<div>Clic gauche : orbite</div>
|
||||
<div>Clic droit : panoramique</div>
|
||||
<div>Molette : zoom</div>
|
||||
<div style={{ marginTop: 4 }}>Cliquer sur un puzzle pour interagir</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
const HUD_STYLE: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
color: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
};
|
||||
|
||||
export function TestMode() {
|
||||
const { engineState, solvedPuzzles, tickEngine } = useSimStore();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
tickEngine(Date.now());
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [tickEngine]);
|
||||
|
||||
return (
|
||||
<div style={HUD_STYLE}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8, color: '#0071e3' }}>
|
||||
Test Mode (10x speed)
|
||||
</div>
|
||||
<div>Phase: {engineState?.phase ?? '—'}</div>
|
||||
<div>Profile: {engineState?.groupProfile ?? 'detecting...'}</div>
|
||||
<div>Solved: {solvedPuzzles.join(', ') || 'none'}</div>
|
||||
<div>Code: {engineState?.codeAssembled ?? '________'}</div>
|
||||
<div>Mood: {engineState?.npcMood ?? '—'}</div>
|
||||
<div>Elapsed: {engineState ? Math.round(engineState.elapsedMs / 1000) : 0}s</div>
|
||||
<div style={{ marginTop: 8, color: 'rgba(255,255,255,0.4)' }}>
|
||||
Click puzzles to solve them
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
const COLORS = ['#ff3b30', '#34c759', '#0071e3', '#ffcc00'];
|
||||
const TARGET = [0, 2, 1, 3]; // melody pattern to reproduce
|
||||
|
||||
function playTone(hz: number, duration = 0.2) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = hz;
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
export function P1Sound({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [pressed, setPressed] = useState<number[]>([]);
|
||||
|
||||
const handleButtonPress = (i: number) => {
|
||||
if (mode === 'demo') return;
|
||||
const newPressed = [...pressed, i];
|
||||
setPressed(newPressed);
|
||||
playTone(220 + i * 110);
|
||||
if (newPressed.length === TARGET.length) {
|
||||
if (newPressed.every((v, j) => v === TARGET[j])) {
|
||||
solvePuzzle('P1_SON');
|
||||
}
|
||||
setPressed([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.8, 0.3, 0.8]} />
|
||||
<meshStandardMaterial color="#2c2c2e" />
|
||||
</mesh>
|
||||
{COLORS.map((color, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(i % 2) * 0.3 - 0.15, 0.2, Math.floor(i / 2) * 0.3 - 0.15]}
|
||||
onClick={() => handleButtonPress(i)}
|
||||
>
|
||||
<cylinderGeometry args={[0.08, 0.08, 0.06, 16]} />
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
))}
|
||||
<Text position={[0, 0.5, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P1 Son
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
// Morse code lookup table
|
||||
const MORSE: Record<string, string> = {
|
||||
A:'.-', B:'-...', C:'-.-.', D:'-..', E:'.', F:'..-.',
|
||||
G:'--.', H:'....', I:'..', J:'.---', K:'-.-', L:'.-..',
|
||||
M:'--', N:'-.', O:'---', P:'.--.', Q:'--.-', R:'.-.',
|
||||
S:'...', T:'-', U:'..-', V:'...-', W:'.--', X:'-..-',
|
||||
Y:'-.--', Z:'--..', '0':'-----', '1':'.----', '2':'..---',
|
||||
'3':'...--', '4':'....-', '5':'.....', '6':'-....', '7':'--...',
|
||||
'8':'---..', '9':'----.',
|
||||
};
|
||||
|
||||
const TARGET_MESSAGE = 'SOS';
|
||||
const targetMorse = TARGET_MESSAGE.split('').map((c) => MORSE[c] ?? '').join(' ');
|
||||
|
||||
function playTone(hz: number, duration = 0.1) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = hz;
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
export function P5Morse({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [active, setActive] = useState(false);
|
||||
const [inputMorse, setInputMorse] = useState('');
|
||||
const pressStartRef = useRef<number>(0);
|
||||
const dotDashThreshold = 200; // ms — below = dot, above = dash
|
||||
|
||||
const handlePress = () => {
|
||||
if (mode === 'demo') return;
|
||||
setActive(true);
|
||||
pressStartRef.current = Date.now();
|
||||
playTone(800, 0.05);
|
||||
};
|
||||
|
||||
const handleRelease = () => {
|
||||
if (mode === 'demo') return;
|
||||
setActive(false);
|
||||
const duration = Date.now() - pressStartRef.current;
|
||||
const symbol = duration < dotDashThreshold ? '.' : '-';
|
||||
setInputMorse((prev) => {
|
||||
const next = prev + symbol;
|
||||
if (next.replace(/ /g, '') === targetMorse.replace(/ /g, '')) {
|
||||
solvePuzzle('P5_MORSE');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Base */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.6, 0.1, 0.4]} />
|
||||
<meshStandardMaterial color="#3a3a3a" metalness={0.7} />
|
||||
</mesh>
|
||||
{/* Telegraph key arm */}
|
||||
<mesh
|
||||
position={[0, active ? 0.05 : 0.1, 0]}
|
||||
onPointerDown={handlePress}
|
||||
onPointerUp={handleRelease}
|
||||
>
|
||||
<boxGeometry args={[0.4, 0.04, 0.08]} />
|
||||
<meshStandardMaterial color="#c0c0c0" metalness={0.9} emissive={active ? '#ffffff' : '#000000'} emissiveIntensity={active ? 0.3 : 0} />
|
||||
</mesh>
|
||||
<Text position={[0, 0.3, 0]} fontSize={0.07} color="white" anchorX="center">
|
||||
P5 Morse
|
||||
</Text>
|
||||
<Text position={[0, 0.42, 0]} fontSize={0.05} color="#00ff00" anchorX="center">
|
||||
{inputMorse.slice(-20)}
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
const SYMBOLS = ['α', 'β', 'γ', 'δ', 'σ', 'ω', 'φ', 'ψ', 'χ', 'ρ', 'μ', 'ν'];
|
||||
const CORRECT_ORDER = [0, 3, 6, 1]; // indices of correct symbols in order
|
||||
const SLOT_COUNT = 4;
|
||||
|
||||
export function P6Symbols({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [slots, setSlots] = useState<(number | null)[]>(Array(SLOT_COUNT).fill(null));
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
const handleTileClick = (idx: number) => {
|
||||
if (mode === 'demo') return;
|
||||
setSelected(idx);
|
||||
};
|
||||
|
||||
const handleSlotClick = (slotIdx: number) => {
|
||||
if (mode === 'demo' || selected === null) return;
|
||||
const newSlots = [...slots];
|
||||
newSlots[slotIdx] = selected;
|
||||
setSlots(newSlots);
|
||||
setSelected(null);
|
||||
// Check if complete and correct
|
||||
if (newSlots.every((s) => s !== null)) {
|
||||
const correct = newSlots.every((s, i) => s === CORRECT_ORDER[i]);
|
||||
if (correct) solvePuzzle('P6_SYMBOLES');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Board */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.8, 0.05, 0.6]} />
|
||||
<meshStandardMaterial color="#5a3a1a" roughness={0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Symbol tiles */}
|
||||
{SYMBOLS.slice(0, 8).map((sym, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(i % 4) * 0.18 - 0.27, 0.08, Math.floor(i / 4) * 0.18 - 0.09]}
|
||||
onClick={() => handleTileClick(i)}
|
||||
>
|
||||
<boxGeometry args={[0.14, 0.04, 0.14]} />
|
||||
<meshStandardMaterial
|
||||
color={selected === i ? '#0071e3' : '#c8a060'}
|
||||
emissive={selected === i ? '#0071e3' : '#000000'}
|
||||
emissiveIntensity={selected === i ? 0.5 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Symbol text labels */}
|
||||
{SYMBOLS.slice(0, 8).map((sym, i) => (
|
||||
<Text
|
||||
key={`sym-${i}`}
|
||||
position={[(i % 4) * 0.18 - 0.27, 0.12, Math.floor(i / 4) * 0.18 - 0.09]}
|
||||
fontSize={0.08}
|
||||
color="#1a1a1a"
|
||||
anchorX="center"
|
||||
>
|
||||
{sym}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{/* Slots */}
|
||||
{Array.from({ length: SLOT_COUNT }, (_, i) => (
|
||||
<mesh
|
||||
key={`slot-${i}`}
|
||||
position={[i * 0.18 - 0.27, 0.08, 0.22]}
|
||||
onClick={() => handleSlotClick(i)}
|
||||
>
|
||||
<boxGeometry args={[0.14, 0.04, 0.14]} />
|
||||
<meshStandardMaterial
|
||||
color={slots[i] !== null ? '#34c759' : '#3a3a3c'}
|
||||
emissive={slots[i] !== null ? '#34c759' : '#000000'}
|
||||
emissiveIntensity={slots[i] !== null ? 0.3 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<Text position={[0, 0.4, 0]} fontSize={0.07} color="white" anchorX="center">
|
||||
P6 Symboles
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
const CORRECT_CODE = '12345678'; // assembled from all puzzles
|
||||
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
|
||||
export function P7Coffre({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [input, setInput] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleKey = (digit: string) => {
|
||||
if (mode === 'demo') return;
|
||||
if (!/^\d$/.test(digit)) return; // ignore * and #
|
||||
const newInput = input + digit;
|
||||
setInput(newInput);
|
||||
setError(false);
|
||||
if (newInput.length === 8) {
|
||||
if (newInput === CORRECT_CODE) {
|
||||
solvePuzzle('P7_COFFRE');
|
||||
} else {
|
||||
setError(true);
|
||||
setTimeout(() => { setInput(''); setError(false); }, 800);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Safe body */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[1, 1, 0.8]} />
|
||||
<meshStandardMaterial color="#2c2c2e" metalness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Keypad grid 3x4 */}
|
||||
{KEYS.map((k, i) => (
|
||||
<mesh
|
||||
key={k}
|
||||
position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.41]}
|
||||
onClick={() => handleKey(k)}
|
||||
>
|
||||
<boxGeometry args={[0.18, 0.14, 0.04]} />
|
||||
<meshStandardMaterial color={error ? '#ff3b30' : '#3a3a3a'} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Key labels */}
|
||||
{KEYS.map((k, i) => (
|
||||
<Text
|
||||
key={`label-${k}-${i}`}
|
||||
position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.44]}
|
||||
fontSize={0.07}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
>
|
||||
{k}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{/* Display */}
|
||||
<Text position={[0, 0.95, 0.41]} fontSize={0.07} color={error ? '#ff3b30' : '#00ff00'} anchorX="center">
|
||||
{input.padEnd(8, '_')}
|
||||
</Text>
|
||||
|
||||
<Text position={[0, 1.2, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P7 Coffre
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
export function NpcBubble() {
|
||||
const npcLastPhrase = useSimStore((s) => s.npcLastPhrase);
|
||||
if (!npcLastPhrase) return null;
|
||||
|
||||
return (
|
||||
<Html position={[0, 1.2, 0]} center>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
color: 'white',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 12,
|
||||
fontSize: 13,
|
||||
maxWidth: 220,
|
||||
textAlign: 'center',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{npcLastPhrase}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Text } from '@react-three/drei';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic puzzle station wrapper: positions content + renders a floating label.
|
||||
*/
|
||||
export function PuzzleStation({ position, label, onClick, children }: Props) {
|
||||
return (
|
||||
<group position={position} {...(onClick ? { onClick } : {})}>
|
||||
{children}
|
||||
<Text position={[0, 0.6, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
{label}
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { P1Sound } from '../puzzles/P1Sound.js';
|
||||
import { P5Morse } from '../puzzles/P5Morse.js';
|
||||
import { P6Symbols } from '../puzzles/P6Symbols.js';
|
||||
import { P7Coffre } from '../puzzles/P7Coffre.js';
|
||||
|
||||
// Positions arranged around the central table (polar layout)
|
||||
const STATION_POSITIONS: Record<string, [number, number, number]> = {
|
||||
P1_SON: [-4, 0, -2],
|
||||
P2_CIRCUIT: [-4, 0, 2],
|
||||
P3_QR: [ 0, 0, -3.5],
|
||||
P4_RADIO: [ 4, 0, -2],
|
||||
P5_MORSE: [ 4, 0, 2],
|
||||
P6_SYMBOLES: [ 0, 0, 3.5],
|
||||
P7_COFFRE: [ 5, 0, 0],
|
||||
};
|
||||
|
||||
function playTone(hz: number, duration = 0.2) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = hz;
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
export function PuzzleStations() {
|
||||
return (
|
||||
<group>
|
||||
<P1Sound position={STATION_POSITIONS['P1_SON']!} />
|
||||
<P2CircuitLed position={STATION_POSITIONS['P2_CIRCUIT']!} />
|
||||
<P3QrTreasure position={STATION_POSITIONS['P3_QR']!} />
|
||||
<P4Radio position={STATION_POSITIONS['P4_RADIO']!} />
|
||||
<P5Morse position={STATION_POSITIONS['P5_MORSE']!} />
|
||||
<P6Symbols position={STATION_POSITIONS['P6_SYMBOLES']!} />
|
||||
<P7Coffre position={STATION_POSITIONS['P7_COFFRE']!} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- P2: Circuit LED ----
|
||||
function P2CircuitLed({ position }: { position: [number, number, number] }) {
|
||||
const { solvePuzzle } = useSimStore();
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow onClick={() => solvePuzzle('P2_CIRCUIT')}>
|
||||
<boxGeometry args={[0.9, 0.1, 0.7]} />
|
||||
<meshStandardMaterial color="#1a2a1a" />
|
||||
</mesh>
|
||||
<Text position={[0, 0.3, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P2 Circuit
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- P3: QR Treasure ----
|
||||
function P3QrTreasure({ position }: { position: [number, number, number] }) {
|
||||
const { solvePuzzle } = useSimStore();
|
||||
const [scanned, setScanned] = useState(0);
|
||||
|
||||
const handleScan = (i: number) => {
|
||||
if (i !== scanned) return; // must scan in order
|
||||
const next = scanned + 1;
|
||||
setScanned(next);
|
||||
if (next === 6) solvePuzzle('P3_QR');
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(i % 3) * 0.3 - 0.3, 1.5, -3.4 + Math.floor(i / 3) * 0.4]}
|
||||
onClick={() => handleScan(i)}
|
||||
>
|
||||
<boxGeometry args={[0.25, 0.25, 0.02]} />
|
||||
<meshStandardMaterial color={i < scanned ? '#34c759' : '#f5f5dc'} />
|
||||
</mesh>
|
||||
))}
|
||||
<Text position={[0, 2.2, -3.4]} fontSize={0.1} color="white" anchorX="center">
|
||||
P3 QR
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- P4: Radio ----
|
||||
function P4Radio({ position }: { position: [number, number, number] }) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const dialRef = useRef<THREE.Mesh>(null);
|
||||
const [freq, setFreq] = useState(88.0);
|
||||
const TARGET_FREQ = 93.5;
|
||||
|
||||
const handleDialDrag = (delta: number) => {
|
||||
if (mode === 'demo') return;
|
||||
const newFreq = Math.max(88, Math.min(108, freq + delta * 0.1));
|
||||
const rounded = parseFloat(newFreq.toFixed(1));
|
||||
setFreq(rounded);
|
||||
if (Math.abs(rounded - TARGET_FREQ) < 0.2) {
|
||||
solvePuzzle('P4_RADIO');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Radio body */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[1.2, 0.6, 0.5]} />
|
||||
<meshStandardMaterial color="#8B4513" roughness={0.8} />
|
||||
</mesh>
|
||||
{/* Dial */}
|
||||
<mesh
|
||||
ref={dialRef}
|
||||
position={[0.3, 0.1, 0.26]}
|
||||
onPointerMove={(e) => {
|
||||
if (e.buttons === 1) handleDialDrag(e.movementX);
|
||||
}}
|
||||
>
|
||||
<cylinderGeometry args={[0.12, 0.12, 0.05, 16]} />
|
||||
<meshStandardMaterial color="#c0c0c0" metalness={0.9} roughness={0.1} />
|
||||
</mesh>
|
||||
{/* Frequency display */}
|
||||
<Text position={[-0.1, 0.05, 0.26]} fontSize={0.08} color="#00ff00">
|
||||
{freq.toFixed(1)} MHz
|
||||
</Text>
|
||||
<Text position={[0, 0.5, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P4 Radio
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Low-poly room geometry: floor, ceiling, 4 walls.
|
||||
*/
|
||||
export function Room() {
|
||||
const walls = [
|
||||
{ pos: [0, 2, -4] as [number, number, number], rot: [0, 0, 0] as [number, number, number], size: [12, 4] as [number, number] },
|
||||
{ pos: [0, 2, 4] as [number, number, number], rot: [0, Math.PI, 0] as [number, number, number], size: [12, 4] as [number, number] },
|
||||
{ pos: [-6, 2, 0] as [number, number, number], rot: [0, Math.PI / 2, 0] as [number, number, number], size: [8, 4] as [number, number] },
|
||||
{ pos: [6, 2, 0] as [number, number, number], rot: [0, -Math.PI / 2, 0] as [number, number, number], size: [8, 4] as [number, number] },
|
||||
];
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[12, 8]} />
|
||||
<meshStandardMaterial color="#1a1a1a" roughness={0.9} metalness={0.1} />
|
||||
</mesh>
|
||||
{/* Ceiling */}
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, 4, 0]}>
|
||||
<planeGeometry args={[12, 8]} />
|
||||
<meshStandardMaterial color="#111111" />
|
||||
</mesh>
|
||||
{/* Walls */}
|
||||
{walls.map((wall, i) => (
|
||||
<mesh key={i} position={wall.pos} rotation={wall.rot} receiveShadow>
|
||||
<planeGeometry args={wall.size} />
|
||||
<meshStandardMaterial color="#1e1e2e" roughness={1} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Environment, Sparkles } from '@react-three/drei';
|
||||
import { Suspense } from 'react';
|
||||
import { Room } from './Room.js';
|
||||
import { PuzzleStations } from './PuzzleStations.js';
|
||||
import { RtcPhone } from './RtcPhone.js';
|
||||
import { NpcBubble } from './NpcBubble.js';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { DemoCameraController } from '../modes/DemoMode.js';
|
||||
|
||||
export function RoomScene() {
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [0, 3, 6], fov: 60 }}
|
||||
shadows
|
||||
gl={{ antialias: true }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.3} />
|
||||
<spotLight
|
||||
position={[0, 5, 0]}
|
||||
angle={0.6}
|
||||
penumbra={0.8}
|
||||
intensity={1.2}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
|
||||
{/* Atmospheric particles */}
|
||||
<Sparkles count={80} scale={[6, 4, 6]} size={1.5} speed={0.2} opacity={0.3} color="#ffccaa" />
|
||||
|
||||
{/* Room geometry */}
|
||||
<Room />
|
||||
|
||||
{/* Central phone / NPC avatar */}
|
||||
<RtcPhone position={[0, 0, 0]} />
|
||||
|
||||
{/* 7 puzzle stations */}
|
||||
<PuzzleStations />
|
||||
|
||||
{/* NPC speech bubbles */}
|
||||
<NpcBubble />
|
||||
|
||||
{/* Camera control */}
|
||||
{mode === 'sandbox' && <OrbitControls makeDefault />}
|
||||
{mode === 'demo' && <DemoCameraController />}
|
||||
|
||||
<Environment preset="night" />
|
||||
<fog attach="fog" args={['#0a0a0f', 8, 20]} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { NPC_MOOD_COLORS } from '@zacus/shared';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
export function RtcPhone({ position }: Props) {
|
||||
const npcMood = useSimStore((s) => s.npcMood);
|
||||
const glowRef = useRef<THREE.PointLight>(null);
|
||||
|
||||
const moodColor = NPC_MOOD_COLORS[npcMood] ?? '#0071e3';
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!glowRef.current) return;
|
||||
// Pulsing glow effect
|
||||
glowRef.current.intensity = 0.5 + 0.3 * Math.sin(clock.elapsedTime * 2);
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Table */}
|
||||
<mesh castShadow position={[0, -0.02, 0]}>
|
||||
<cylinderGeometry args={[0.8, 0.8, 0.04, 32]} />
|
||||
<meshStandardMaterial color="#3a2a1a" roughness={0.8} />
|
||||
</mesh>
|
||||
{/* Phone body */}
|
||||
<mesh castShadow position={[0, 0.15, 0]}>
|
||||
<boxGeometry args={[0.3, 0.25, 0.15]} />
|
||||
<meshStandardMaterial color="#1a1a1a" metalness={0.4} />
|
||||
</mesh>
|
||||
{/* Handset */}
|
||||
<mesh castShadow position={[0, 0.32, 0]} rotation={[0, 0, -0.3]}>
|
||||
<boxGeometry args={[0.2, 0.06, 0.06]} />
|
||||
<meshStandardMaterial color="#111111" />
|
||||
</mesh>
|
||||
{/* Mood glow */}
|
||||
<pointLight ref={glowRef} color={moodColor} distance={2} />
|
||||
<mesh position={[0, 0.1, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshStandardMaterial color={moodColor} emissive={moodColor} emissiveIntensity={1} transparent opacity={0.7} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Workbench selection state (phase C): which scenario step the device-screen
|
||||
* preview and flow graph are focused on. `null` falls back to the entry step.
|
||||
*/
|
||||
interface SelectionState {
|
||||
selectedStepId: string | null;
|
||||
selectStep: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useSelectionStore = create<SelectionState>((set) => ({
|
||||
selectedStepId: null,
|
||||
selectStep: (selectedStepId) => set({ selectedStepId }),
|
||||
}));
|
||||
@@ -1,75 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { NpcMood, EngineState } from '@zacus/shared';
|
||||
import { ZacusScenarioEngine } from '@zacus/scenario-engine';
|
||||
|
||||
export type SimMode = 'demo' | 'sandbox' | 'test';
|
||||
|
||||
interface SimState {
|
||||
mode: SimMode;
|
||||
npcMood: NpcMood;
|
||||
npcLastPhrase: string | null;
|
||||
solvedPuzzles: string[];
|
||||
engineState: EngineState | null;
|
||||
engine: ZacusScenarioEngine | null;
|
||||
|
||||
setMode: (mode: SimMode) => void;
|
||||
solvePuzzle: (id: string) => void;
|
||||
loadScenario: (yaml: string) => void;
|
||||
tickEngine: (nowMs: number) => void;
|
||||
speakNpc: (phrase: string) => void;
|
||||
}
|
||||
|
||||
export const useSimStore = create<SimState>((set, get) => ({
|
||||
mode: 'sandbox',
|
||||
npcMood: 'neutral',
|
||||
npcLastPhrase: null,
|
||||
solvedPuzzles: [],
|
||||
engineState: null,
|
||||
engine: null,
|
||||
|
||||
setMode(mode) { set({ mode }); },
|
||||
|
||||
loadScenario(yaml) {
|
||||
const engine = new ZacusScenarioEngine();
|
||||
engine.load(yaml);
|
||||
engine.start({ targetDuration: 60, mode: '60' });
|
||||
set({ engine, engineState: engine.getState() });
|
||||
},
|
||||
|
||||
tickEngine(nowMs) {
|
||||
const { engine } = get();
|
||||
if (!engine) return;
|
||||
const state = engine.tick(nowMs);
|
||||
set({ engineState: state, npcMood: state.npcMood });
|
||||
},
|
||||
|
||||
solvePuzzle(id) {
|
||||
const { engine } = get();
|
||||
set((s) => ({ solvedPuzzles: [...s.solvedPuzzles, id] }));
|
||||
if (engine) {
|
||||
const decisions = engine.onEvent({
|
||||
type: 'puzzle_solved',
|
||||
timestamp: Date.now(),
|
||||
data: { puzzle_id: id },
|
||||
});
|
||||
for (const d of decisions) {
|
||||
if (d.action === 'speak') {
|
||||
const phrase = `Excellent ! Puzzle ${id} résolu !`;
|
||||
get().speakNpc(phrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
speakNpc(phrase) {
|
||||
set({ npcLastPhrase: phrase });
|
||||
// TTS via Web Speech API
|
||||
if ('speechSynthesis' in window) {
|
||||
const utt = new SpeechSynthesisUtterance(phrase);
|
||||
utt.lang = 'fr-FR';
|
||||
window.speechSynthesis.speak(utt);
|
||||
}
|
||||
// Clear bubble after 5s
|
||||
setTimeout(() => set({ npcLastPhrase: null }), 5000);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
import type * as Blockly from 'blockly';
|
||||
|
||||
/**
|
||||
* Holds the live Blockly workspace instance so non-editor components (the
|
||||
* MenuBar load/share actions) can read/write blocks without prop-drilling the
|
||||
* imperative ref through the layout tree. Registered by EditorPane once the
|
||||
* lazy Blockly workspace mounts; stays focused on the handle only.
|
||||
*/
|
||||
export interface WorkspaceState {
|
||||
workspace: Blockly.WorkspaceSvg | null;
|
||||
setWorkspace: (workspace: Blockly.WorkspaceSvg | null) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
|
||||
workspace: null,
|
||||
setWorkspace: (workspace) => set({ workspace }),
|
||||
}));
|
||||
@@ -0,0 +1,9 @@
|
||||
// Palette néo-rétro « Workbench » — alignée sur display_ui.cpp et lib/screenSpec.ts.
|
||||
export const WB = {
|
||||
bg: '#0a1722', panel: '#0f2233', panelBorder: '#1c3b52',
|
||||
workbenchBlue: '#0055AA', orange: '#FF8800', value: '#FFFFFF', label: '#AAAAAA',
|
||||
text: '#e8eef3', gridSubtle: '#13283a',
|
||||
led: { ok: '#34d399', warn: '#fbbf24', err: '#fca5a5', off: '#3b4a57' },
|
||||
fontTitle: "'Orbitron','Arial Narrow',sans-serif",
|
||||
fontMono: "'IBM Plex Mono',ui-monospace,monospace",
|
||||
} as const;
|
||||
+11
@@ -1 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** Base URL of the zacus-gateway (e.g. https://zacus.saillant.cc/api). */
|
||||
readonly VITE_GATEWAY_BASE_URL?: string;
|
||||
/** Optional bearer token for the gateway (gen_token.py). */
|
||||
readonly VITE_GATEWAY_TOKEN?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"types": ["@react-three/fiber"]
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
|
||||
@@ -9,7 +9,6 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: {
|
||||
blockly: ['blockly'],
|
||||
three: ['three', '@react-three/fiber', '@react-three/drei'],
|
||||
react: ['react', 'react-dom'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_GATEWAY_BASE_URL=https://zacus.saillant.cc/api
|
||||
@@ -4,53 +4,32 @@ import { test, expect } from '@playwright/test';
|
||||
* Atelier golden-path smoke tests.
|
||||
*
|
||||
* The dev server is auto-started by playwright.config.ts (webServer).
|
||||
* Tests assert: layout renders, lazy chunks load, ⌘B collapses the 3D
|
||||
* stage, the Run button surfaces after a debounced editor change.
|
||||
* Tests assert: layout renders, lazy Blockly chunk loads, ⌘B collapses the
|
||||
* stage panel, the Run button surfaces after a debounced editor change.
|
||||
*
|
||||
* The Run-button test uses a dev-only window.__atelierStores hook
|
||||
* (declared in App.tsx behind import.meta.env.DEV) to drive store
|
||||
* state without dragging real Blockly blocks in headless mode.
|
||||
*
|
||||
* NOTE: the 3D simulation stage was removed (phase A of the workbench
|
||||
* refactor). The right panel is now a placeholder ("Banc d'essai");
|
||||
* there is no <canvas>, no mode tabs, and no simStore.
|
||||
*/
|
||||
|
||||
test.describe('atelier — layout & lazy chunks', () => {
|
||||
test('shell renders three drag-resizable panes', async ({ page }) => {
|
||||
test('shell renders editor and stage placeholder panes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: 'sandbox' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'demo' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'test' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('mode switching updates the active tab', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const sandboxTab = page.getByRole('button', { name: 'sandbox' });
|
||||
const testTab = page.getByRole('button', { name: 'test' });
|
||||
|
||||
await expect(sandboxTab).toHaveClass(/atelier-mode-tab--active/);
|
||||
|
||||
await testTab.click();
|
||||
await expect(testTab).toHaveClass(/atelier-mode-tab--active/);
|
||||
await expect(sandboxTab).not.toHaveClass(/atelier-mode-tab--active/);
|
||||
|
||||
await expect(page.getByText('Test Mode (10x speed)')).toBeVisible();
|
||||
await expect(page.getByTestId('stage-pane')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('atelier — ⌘B stage toggle', () => {
|
||||
test('toggleStage collapses and re-expands the 3D stage', async ({ page }) => {
|
||||
test('toggleStage collapses and re-expands the stage panel', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('stage-pane')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// We assert on the StagePane wrapper, not the <canvas> element.
|
||||
// R3F's <canvas> keeps its intrinsic width (574px) even when the
|
||||
// parent flex-container shrinks to 0 — only a resize event would
|
||||
// tell Three to recompute. The stage-pane div, on the other hand,
|
||||
// follows its parent panel's flex size faithfully.
|
||||
//
|
||||
// We invoke window.__atelierToggleStage directly rather than firing
|
||||
// Cmd+B / Ctrl+B because keyboard modifier handling in headless
|
||||
// Chromium is unreliable across macOS / Linux CI runners. The keyboard
|
||||
@@ -80,7 +59,7 @@ test.describe('atelier — ⌘B stage toggle', () => {
|
||||
test.describe('atelier — Run button stale flow', () => {
|
||||
test('Run button surfaces after a debounced editor change', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Initially neither badge nor button is rendered.
|
||||
await expect(page.getByRole('button', { name: /Run|stale/ })).toHaveCount(0);
|
||||
@@ -131,7 +110,7 @@ test.describe('atelier — Blockly editor pipeline', () => {
|
||||
const ws = (
|
||||
window as unknown as { __atelierBlockly: { getWorkspace: () => WS } }
|
||||
).__atelierBlockly.getWorkspace();
|
||||
const block = ws.newBlock('puzzle_sequence_sonore');
|
||||
const block = ws.newBlock('zacus_sceneStart');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
// Give Blockly its synchronous tick to fire change events.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Vague 1 — reporter blocks → condition string → boolean.
|
||||
*
|
||||
* The scenario conditions are opaque strings in a loose grammar
|
||||
* (`score > 5`, `pct >= 60 and lives > 0`). The new Blockly reporter blocks
|
||||
* compose visually and compile to that SAME string via
|
||||
* `serializeReporter()` (shared, mirrored in the Python gateway). This test
|
||||
* proves the round-trip: build reporter nodes → serialise → the resulting
|
||||
* string evaluates as expected against a variable scope.
|
||||
*
|
||||
* The evaluator here is a tiny reference implementation of the loose grammar
|
||||
* (the runtime/firmware owns the production one; the spec leaves `expr`
|
||||
* evaluation deliberately loose). It exists to assert the serialisation is
|
||||
* semantically correct, not to ship an engine evaluator.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { serializeReporter, type BlocksNode } from '@zacus/shared';
|
||||
|
||||
function byId(...nodes: BlocksNode[]): Record<string, BlocksNode> {
|
||||
const m: Record<string, BlocksNode> = {};
|
||||
for (const n of nodes) m[n.id] = n;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Reference evaluator for the loose condition grammar. Supports numbers,
|
||||
* string literals, identifiers (from scope), comparisons, + - * /, and the
|
||||
* `and`/`or`/`not` keywords. JS-based, parens-aware. */
|
||||
function evalExpr(expr: string, scope: Record<string, number | string>): boolean {
|
||||
// Translate the keyword operators to JS, keep === / !== loose-equality safe.
|
||||
const js = expr
|
||||
.replace(/\band\b/g, '&&')
|
||||
.replace(/\bor\b/g, '||')
|
||||
.replace(/\bnot\b/g, '!')
|
||||
.replace(/(^|[^=!<>])={2}(?!=)/g, '$1===')
|
||||
.replace(/!={1}(?!=)/g, '!==');
|
||||
const names = Object.keys(scope);
|
||||
const vals = names.map((k) => scope[k]);
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function(...names, `return (${js});`);
|
||||
return Boolean(fn(...vals));
|
||||
}
|
||||
|
||||
describe('reporter → condition string', () => {
|
||||
it('valScore serialises to "score"', () => {
|
||||
const score: BlocksNode = { id: 's', kind: 'valScore', position: [0, 0] };
|
||||
expect(serializeReporter('s', byId(score))).toBe('score');
|
||||
});
|
||||
|
||||
it('compare(score, ">", 5) → "score > 5" and evaluates correctly', () => {
|
||||
const score: BlocksNode = { id: 's', kind: 'valScore', position: [0, 0] };
|
||||
const five: BlocksNode = { id: 'n', kind: 'valNumber', position: [0, 0], params: { num: 5 } };
|
||||
const cmp: BlocksNode = {
|
||||
id: 'c', kind: 'opCompare', position: [0, 0],
|
||||
params: { op: '>' }, inputs: { A: 's', B: 'n' },
|
||||
};
|
||||
const expr = serializeReporter('c', byId(score, five, cmp));
|
||||
expect(expr).toBe('score > 5');
|
||||
expect(evalExpr(expr, { score: 7 })).toBe(true);
|
||||
expect(evalExpr(expr, { score: 3 })).toBe(false);
|
||||
expect(evalExpr(expr, { score: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it('var("pct") → "pct"', () => {
|
||||
const v: BlocksNode = { id: 'v', kind: 'valVar', position: [0, 0], params: { name: 'pct' } };
|
||||
expect(serializeReporter('v', byId(v))).toBe('pct');
|
||||
});
|
||||
|
||||
it('and(compare, compare) wraps operands in parens', () => {
|
||||
const nodes = byId(
|
||||
{ id: 'pct', kind: 'valVar', position: [0, 0], params: { name: 'pct' } },
|
||||
{ id: 'p60', kind: 'valNumber', position: [0, 0], params: { num: 60 } },
|
||||
{ id: 'lives', kind: 'valVar', position: [0, 0], params: { name: 'lives' } },
|
||||
{ id: 'l0', kind: 'valNumber', position: [0, 0], params: { num: 0 } },
|
||||
{ id: 'cA', kind: 'opCompare', position: [0, 0], params: { op: '>=' }, inputs: { A: 'pct', B: 'p60' } },
|
||||
{ id: 'cB', kind: 'opCompare', position: [0, 0], params: { op: '>' }, inputs: { A: 'lives', B: 'l0' } },
|
||||
{ id: 'andN', kind: 'opBool', position: [0, 0], params: { op: 'and' }, inputs: { A: 'cA', B: 'cB' } },
|
||||
);
|
||||
const expr = serializeReporter('andN', nodes);
|
||||
expect(expr).toBe('(pct >= 60) and (lives > 0)');
|
||||
expect(evalExpr(expr, { pct: 80, lives: 2 })).toBe(true);
|
||||
expect(evalExpr(expr, { pct: 80, lives: 0 })).toBe(false);
|
||||
expect(evalExpr(expr, { pct: 50, lives: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('not(var) → "not (flag)"', () => {
|
||||
const nodes = byId(
|
||||
{ id: 'f', kind: 'valVar', position: [0, 0], params: { name: 'flag' } },
|
||||
{ id: 'n', kind: 'opNot', position: [0, 0], inputs: { A: 'f' } },
|
||||
);
|
||||
const expr = serializeReporter('n', nodes);
|
||||
expect(expr).toBe('not (flag)');
|
||||
expect(evalExpr(expr, { flag: 0 })).toBe(true);
|
||||
expect(evalExpr(expr, { flag: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('arith(score + 1) inside a compare', () => {
|
||||
const nodes = byId(
|
||||
{ id: 's', kind: 'valScore', position: [0, 0] },
|
||||
{ id: 'one', kind: 'valNumber', position: [0, 0], params: { num: 1 } },
|
||||
{ id: 'sum', kind: 'opArith', position: [0, 0], params: { op: '+' }, inputs: { A: 's', B: 'one' } },
|
||||
{ id: 'ten', kind: 'valNumber', position: [0, 0], params: { num: 10 } },
|
||||
{ id: 'cmp', kind: 'opCompare', position: [0, 0], params: { op: '>=' }, inputs: { A: 'sum', B: 'ten' } },
|
||||
);
|
||||
const expr = serializeReporter('cmp', nodes);
|
||||
expect(expr).toBe('(score + 1) >= 10');
|
||||
expect(evalExpr(expr, { score: 9 })).toBe(true);
|
||||
expect(evalExpr(expr, { score: 8 })).toBe(false);
|
||||
});
|
||||
|
||||
it('text literal is JSON-quoted', () => {
|
||||
const nodes = byId(
|
||||
{ id: 'g', kind: 'valVar', position: [0, 0], params: { name: 'guess' } },
|
||||
{ id: 't', kind: 'valText', position: [0, 0], params: { text: 'oui' } },
|
||||
{ id: 'c', kind: 'opCompare', position: [0, 0], params: { op: '==' }, inputs: { A: 'g', B: 't' } },
|
||||
);
|
||||
const expr = serializeReporter('c', nodes);
|
||||
expect(expr).toBe('guess == "oui"');
|
||||
expect(evalExpr(expr, { guess: 'oui' })).toBe(true);
|
||||
expect(evalExpr(expr, { guess: 'non' })).toBe(false);
|
||||
});
|
||||
|
||||
it('missing operand ref falls back to empty (so callers use text param)', () => {
|
||||
expect(serializeReporter(undefined, {})).toBe('');
|
||||
expect(serializeReporter('nope', {})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evalExpr, evalBool, evalNumber, ExprError } from '../src/expr-eval.js';
|
||||
|
||||
describe('expr-eval — literals', () => {
|
||||
it('evaluates a number literal', () => {
|
||||
expect(evalExpr('42')).toBe(42);
|
||||
expect(evalExpr('3.5')).toBe(3.5);
|
||||
expect(evalExpr(' 7 ')).toBe(7);
|
||||
});
|
||||
|
||||
it('evaluates a text literal (double and single quotes)', () => {
|
||||
expect(evalExpr('"oui"')).toBe('oui');
|
||||
expect(evalExpr("'non'")).toBe('non');
|
||||
});
|
||||
|
||||
it('reads variables from the scope', () => {
|
||||
expect(evalExpr('score', { score: 12 })).toBe(12);
|
||||
expect(evalExpr('pct', { pct: 60 })).toBe(60);
|
||||
expect(evalExpr('guess', { guess: 'oui' })).toBe('oui');
|
||||
});
|
||||
|
||||
it('treats an unknown variable as 0 (loose grammar)', () => {
|
||||
expect(evalExpr('missing')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expr-eval — arithmetic', () => {
|
||||
it('handles + - * / with precedence', () => {
|
||||
expect(evalExpr('2 + 3 * 4')).toBe(14);
|
||||
expect(evalExpr('(2 + 3) * 4')).toBe(20);
|
||||
expect(evalExpr('10 / 4')).toBe(2.5);
|
||||
expect(evalExpr('10 - 3 - 2')).toBe(5);
|
||||
});
|
||||
|
||||
it('handles unary minus', () => {
|
||||
expect(evalExpr('-5')).toBe(-5);
|
||||
expect(evalExpr('3 + -2')).toBe(1);
|
||||
expect(evalExpr('-(2 + 3)')).toBe(-5);
|
||||
});
|
||||
|
||||
it('arithmetic with variables', () => {
|
||||
expect(evalExpr('score + 1', { score: 9 })).toBe(10);
|
||||
expect(evalExpr('score * 2 - 1', { score: 5 })).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expr-eval — comparisons', () => {
|
||||
it('==, !=, <, <=, >, >=', () => {
|
||||
expect(evalExpr('score > 5', { score: 7 })).toBe(true);
|
||||
expect(evalExpr('score > 5', { score: 5 })).toBe(false);
|
||||
expect(evalExpr('score >= 5', { score: 5 })).toBe(true);
|
||||
expect(evalExpr('score < 5', { score: 3 })).toBe(true);
|
||||
expect(evalExpr('score <= 5', { score: 6 })).toBe(false);
|
||||
expect(evalExpr('score == 5', { score: 5 })).toBe(true);
|
||||
expect(evalExpr('score != 5', { score: 6 })).toBe(true);
|
||||
});
|
||||
|
||||
it('compares strings with == / !=', () => {
|
||||
expect(evalExpr('guess == "oui"', { guess: 'oui' })).toBe(true);
|
||||
expect(evalExpr('guess == "oui"', { guess: 'non' })).toBe(false);
|
||||
expect(evalExpr('guess != "oui"', { guess: 'non' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expr-eval — booleans and/or/not', () => {
|
||||
it('and / or', () => {
|
||||
expect(evalExpr('(pct >= 60) and (lives > 0)', { pct: 80, lives: 2 })).toBe(true);
|
||||
expect(evalExpr('(pct >= 60) and (lives > 0)', { pct: 80, lives: 0 })).toBe(false);
|
||||
expect(evalExpr('(pct >= 60) and (lives > 0)', { pct: 50, lives: 2 })).toBe(false);
|
||||
expect(evalExpr('(pct >= 60) or (lives > 0)', { pct: 50, lives: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it('not', () => {
|
||||
expect(evalExpr('not (flag)', { flag: 0 })).toBe(true);
|
||||
expect(evalExpr('not (flag)', { flag: 1 })).toBe(false);
|
||||
expect(evalExpr('not (pct >= 60)', { pct: 50 })).toBe(true);
|
||||
});
|
||||
|
||||
it('and binds tighter than or', () => {
|
||||
// false or (true and true) => true
|
||||
expect(evalExpr('a or b and c', { a: 0, b: 1, c: 1 })).toBe(true);
|
||||
// (false or true) and false would be false; correct precedence => true
|
||||
expect(evalExpr('a or b and c', { a: 0, b: 1, c: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('arith inside compare inside bool', () => {
|
||||
expect(evalExpr('(score + 1) >= 10', { score: 9 })).toBe(true);
|
||||
expect(evalExpr('(score + 1) >= 10', { score: 8 })).toBe(false);
|
||||
});
|
||||
|
||||
it('robust to extra whitespace and nested parens', () => {
|
||||
expect(evalExpr(' ( ( score > 5 ) and ( not (flag) ) ) ', { score: 9, flag: 0 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expr-eval — helpers', () => {
|
||||
it('evalBool coerces to boolean (truthy numbers)', () => {
|
||||
expect(evalBool('score', { score: 3 })).toBe(true);
|
||||
expect(evalBool('score', { score: 0 })).toBe(false);
|
||||
expect(evalBool('score > 5', { score: 7 })).toBe(true);
|
||||
});
|
||||
|
||||
it('evalNumber coerces booleans to 1/0', () => {
|
||||
expect(evalNumber('score > 5', { score: 7 })).toBe(1);
|
||||
expect(evalNumber('score + 2', { score: 5 })).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expr-eval — errors', () => {
|
||||
it('throws ExprError on malformed input', () => {
|
||||
expect(() => evalExpr('2 +')).toThrow(ExprError);
|
||||
expect(() => evalExpr('(2 + 3')).toThrow(ExprError);
|
||||
expect(() => evalExpr('2 3')).toThrow(ExprError);
|
||||
expect(() => evalExpr('')).toThrow(ExprError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpFirmwareDriver } from '../src/firmware-driver.js';
|
||||
import type { RuntimeIR } from '../src/runtime-ir.js';
|
||||
|
||||
const ir: RuntimeIR = {
|
||||
schema_version: 'zacus.runtime3.v1',
|
||||
scenario: { id: 'TEST', version: 3, title: 'T', entry_step_id: 'S1' },
|
||||
steps: [{ id: 'S1' }],
|
||||
};
|
||||
|
||||
function okJson(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('HttpFirmwareDriver', () => {
|
||||
it('pushScenario POSTs the IR to /game/scenario', async () => {
|
||||
const fetchMock = vi.fn(async () => okJson({ ok: true }));
|
||||
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
|
||||
await driver.pushScenario(ir);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('http://master.local/game/scenario');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ scenario: { id: 'TEST' } });
|
||||
});
|
||||
|
||||
it('armStep POSTs {step_id} to /game/step', async () => {
|
||||
const fetchMock = vi.fn(async () => okJson({ ok: true }));
|
||||
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
|
||||
await driver.armStep('S1');
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('http://master.local/game/step');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ step_id: 'S1' });
|
||||
});
|
||||
|
||||
it('getPuzzleState GETs /game/puzzle_state and parses JSON', async () => {
|
||||
const fetchMock = vi.fn(async () => okJson({ solved: true, assembled_code: '125' }));
|
||||
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
|
||||
const state = await driver.getPuzzleState();
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('http://master.local/game/puzzle_state');
|
||||
expect(init?.method ?? 'GET').toBe('GET');
|
||||
expect(state).toMatchObject({ solved: true, assembled_code: '125' });
|
||||
});
|
||||
|
||||
it('setGroupProfile POSTs to /game/group_profile', async () => {
|
||||
const fetchMock = vi.fn(async () => okJson({ ok: true }));
|
||||
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
|
||||
await driver.setGroupProfile({ profile: 'TECH' });
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('http://master.local/game/group_profile');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ profile: 'TECH' });
|
||||
});
|
||||
|
||||
it('strips a trailing slash from the base URL', async () => {
|
||||
const fetchMock = vi.fn(async () => okJson({ ok: true }));
|
||||
const driver = new HttpFirmwareDriver('http://master.local/', { fetch: fetchMock });
|
||||
await driver.armStep('S1');
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe('http://master.local/game/step');
|
||||
});
|
||||
|
||||
it('throws on a non-2xx response', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
|
||||
const driver = new HttpFirmwareDriver('http://master.local', { fetch: fetchMock });
|
||||
await expect(driver.armStep('S1')).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ScenarioRunner } from '../src/runner.js';
|
||||
import type { RuntimeIR, RuntimeAction } from '../src/runtime-ir.js';
|
||||
import type { FirmwareDriver, PuzzleState } from '../src/firmware-driver.js';
|
||||
|
||||
function ir(steps: RuntimeIR['steps'], entry?: string): RuntimeIR {
|
||||
return {
|
||||
schema_version: 'zacus.runtime3.v1',
|
||||
scenario: {
|
||||
id: 'TEST',
|
||||
version: 3,
|
||||
title: 'Test',
|
||||
entry_step_id: entry ?? steps[0]!.id,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
// A stub driver that records calls; never touches the network.
|
||||
function stubDriver(state: Partial<PuzzleState> = {}): FirmwareDriver & { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
async pushScenario(doc) {
|
||||
calls.push(`push:${doc.scenario.id}`);
|
||||
},
|
||||
async armStep(stepId) {
|
||||
calls.push(`arm:${stepId}`);
|
||||
},
|
||||
async getPuzzleState() {
|
||||
calls.push('state');
|
||||
return { solved: true, assembled_code: '', ...state };
|
||||
},
|
||||
async setGroupProfile(p) {
|
||||
calls.push(`profile:${p.profile ?? ''}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScenarioRunner — expressions / actions', () => {
|
||||
it('executes set_var and score_add actions, updating scope', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{ kind: 'score_add', delta: 5 },
|
||||
{ kind: 'set_var', name: 'flag', value: 'true' },
|
||||
] }]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.getVar('score')).toBe(5);
|
||||
expect(runner.getVar('flag')).toBe('true');
|
||||
});
|
||||
|
||||
it('condition action runs the then-branch when expr is truthy', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{ kind: 'score_add', delta: 4 },
|
||||
{
|
||||
kind: 'condition',
|
||||
expr: 'score >= 3',
|
||||
then: [{ kind: 'set_var', name: 'level', value: '2' }],
|
||||
else: [{ kind: 'set_var', name: 'level', value: '1' }],
|
||||
},
|
||||
] }]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.getVar('level')).toBe('2');
|
||||
});
|
||||
|
||||
it('condition action runs the else-branch when expr is falsy', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{
|
||||
kind: 'condition',
|
||||
expr: 'score >= 3',
|
||||
then: [{ kind: 'set_var', name: 'level', value: '2' }],
|
||||
else: [{ kind: 'set_var', name: 'level', value: '1' }],
|
||||
},
|
||||
] }]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.getVar('level')).toBe('1');
|
||||
});
|
||||
|
||||
it('emits non-control actions (tts_say) to the sink', () => {
|
||||
const emitted: RuntimeAction[] = [];
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [{ kind: 'tts_say', text: 'hello' }] }]),
|
||||
{ onAction: (a) => emitted.push(a) },
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted[0]).toMatchObject({ kind: 'tts_say', text: 'hello' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScenarioRunner — loop execution', () => {
|
||||
it('repeat loop runs body N times', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{ kind: 'loop', mode: 'repeat', times: 3, body: [{ kind: 'score_add', delta: 2 }] },
|
||||
] }]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.getVar('score')).toBe(6);
|
||||
});
|
||||
|
||||
it('while loop runs until expr is falsy', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{ kind: 'loop', mode: 'while', expr: 'score < 10', body: [{ kind: 'score_add', delta: 3 }] },
|
||||
] }]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.getVar('score')).toBe(12); // 0,3,6,9 -> +3 -> 12, then 12<10 false
|
||||
});
|
||||
|
||||
it('while loop is bounded (no infinite loop)', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{ kind: 'loop', mode: 'while', expr: '1 > 0', body: [{ kind: 'tts_say', text: 'x' }] },
|
||||
] }]),
|
||||
{ maxLoopIterations: 50 },
|
||||
);
|
||||
expect(() => runner.runStep('S1')).toThrow(/loop iteration limit/i);
|
||||
});
|
||||
|
||||
it('nested loop + condition compose', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([{ id: 'S1', actions: [
|
||||
{
|
||||
kind: 'loop', mode: 'repeat', times: 5, body: [
|
||||
{ kind: 'score_add', delta: 1 },
|
||||
{
|
||||
kind: 'condition', expr: 'score == 3',
|
||||
then: [{ kind: 'set_var', name: 'hit3', value: 'yes' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
] }]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.getVar('score')).toBe(5);
|
||||
expect(runner.getVar('hit3')).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScenarioRunner — transitions', () => {
|
||||
it('selects unconditional transition by priority', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([
|
||||
{ id: 'S1', transitions: [
|
||||
{ event_type: 'action', target_step_id: 'B', priority: 1 },
|
||||
{ event_type: 'action', target_step_id: 'A', priority: 0 },
|
||||
] },
|
||||
{ id: 'A' },
|
||||
{ id: 'B' },
|
||||
]),
|
||||
);
|
||||
expect(runner.nextStep('S1')).toBe('A');
|
||||
});
|
||||
|
||||
it('branch transition uses expr to pick target', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([
|
||||
{ id: 'S1', actions: [{ kind: 'score_add', delta: 9 }], transitions: [
|
||||
{ event_type: 'branch', target_step_id: 'WIN', expr: 'score >= 5', priority: 0 },
|
||||
{ event_type: 'branch', target_step_id: 'LOSE', priority: 1 },
|
||||
] },
|
||||
{ id: 'WIN' },
|
||||
{ id: 'LOSE' },
|
||||
]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.nextStep('S1')).toBe('WIN');
|
||||
});
|
||||
|
||||
it('branch transition skips when its expr is falsy', () => {
|
||||
const runner = new ScenarioRunner(
|
||||
ir([
|
||||
{ id: 'S1', transitions: [
|
||||
{ event_type: 'branch', target_step_id: 'WIN', expr: 'score >= 5', priority: 0 },
|
||||
{ event_type: 'branch', target_step_id: 'LOSE', priority: 1 },
|
||||
] },
|
||||
{ id: 'WIN' },
|
||||
{ id: 'LOSE' },
|
||||
]),
|
||||
);
|
||||
runner.runStep('S1');
|
||||
expect(runner.nextStep('S1')).toBe('LOSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScenarioRunner — firmware driver integration', () => {
|
||||
it('pushes scenario and arms entry step on start()', async () => {
|
||||
const driver = stubDriver();
|
||||
const runner = new ScenarioRunner(
|
||||
ir([
|
||||
{ id: 'S1', puzzle: { id: 1, type: 'sound', melody: [60], fragment: [1] },
|
||||
transitions: [{ event_type: 'unlock', target_step_id: 'S2' }] },
|
||||
{ id: 'S2' },
|
||||
]),
|
||||
{ driver },
|
||||
);
|
||||
await runner.start();
|
||||
expect(driver.calls).toContain('push:TEST');
|
||||
expect(driver.calls).toContain('arm:S1');
|
||||
});
|
||||
|
||||
it('advance() waits for puzzle solved then moves to next step', async () => {
|
||||
const driver = stubDriver({ solved: true });
|
||||
const runner = new ScenarioRunner(
|
||||
ir([
|
||||
{ id: 'S1', puzzle: { id: 1, type: 'sound', melody: [60], fragment: [1] },
|
||||
transitions: [{ event_type: 'unlock', target_step_id: 'S2' }] },
|
||||
{ id: 'S2' },
|
||||
]),
|
||||
{ driver, pollIntervalMs: 1 },
|
||||
);
|
||||
await runner.start();
|
||||
const moved = await runner.advance();
|
||||
expect(moved).toBe('S2');
|
||||
expect(runner.currentStepId()).toBe('S2');
|
||||
expect(driver.calls).toContain('arm:S2');
|
||||
});
|
||||
|
||||
it('advance() polls puzzle_state until solved', async () => {
|
||||
let n = 0;
|
||||
const driver: FirmwareDriver & { calls: string[] } = {
|
||||
calls: [],
|
||||
async pushScenario() {},
|
||||
async armStep(s) { this.calls.push(`arm:${s}`); },
|
||||
async getPuzzleState() {
|
||||
n++;
|
||||
return { solved: n >= 3, assembled_code: '' };
|
||||
},
|
||||
async setGroupProfile() {},
|
||||
};
|
||||
const runner = new ScenarioRunner(
|
||||
ir([
|
||||
{ id: 'S1', puzzle: { id: 1, type: 'qr', codes: ['x'], fragment: [1] },
|
||||
transitions: [{ event_type: 'unlock', target_step_id: 'S2' }] },
|
||||
{ id: 'S2' },
|
||||
]),
|
||||
{ driver, pollIntervalMs: 1 },
|
||||
);
|
||||
await runner.start();
|
||||
await runner.advance();
|
||||
expect(n).toBeGreaterThanOrEqual(3);
|
||||
expect(runner.currentStepId()).toBe('S2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Evaluator for the Zacus "loose" condition grammar.
|
||||
*
|
||||
* The condition / loop `expr` strings are serialised by the Blocks Studio
|
||||
* (`serializeReporter`, mirrored in the Python gateway) into a small grammar:
|
||||
*
|
||||
* - variables (identifiers, incl. the special `score`) — read from a scope
|
||||
* - number literals (`5`, `3.5`) and text literals (`"oui"`, `'non'`)
|
||||
* - comparisons: `==` `!=` `<` `<=` `>` `>=`
|
||||
* - arithmetic: `+` `-` `*` `/` (and unary `-`)
|
||||
* - booleans: `and` `or` `not`
|
||||
* - parentheses for grouping
|
||||
*
|
||||
* This is a real parser (tokeniser + recursive descent) — NOT a JS `eval()`.
|
||||
* It returns `number | string | boolean`. Helpers `evalBool` / `evalNumber`
|
||||
* coerce to the type a caller needs.
|
||||
*
|
||||
* Grammar (lowest to highest precedence):
|
||||
* expr := orExpr
|
||||
* orExpr := andExpr ( "or" andExpr )*
|
||||
* andExpr := notExpr ( "and" notExpr )*
|
||||
* notExpr := "not" notExpr | comparison
|
||||
* comparison := add ( ("=="|"!="|"<"|"<="|">"|">=") add )?
|
||||
* add := mul ( ("+"|"-") mul )*
|
||||
* mul := unary ( ("*"|"/") unary )*
|
||||
* unary := "-" unary | primary
|
||||
* primary := number | string | identifier | "(" expr ")"
|
||||
*/
|
||||
|
||||
export type ExprScope = Record<string, number | string | boolean>;
|
||||
export type ExprValue = number | string | boolean;
|
||||
|
||||
export class ExprError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ExprError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokeniser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TokenType =
|
||||
| 'number'
|
||||
| 'string'
|
||||
| 'ident'
|
||||
| 'op' // arithmetic / comparison operator
|
||||
| 'kw' // and / or / not
|
||||
| 'lparen'
|
||||
| 'rparen';
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
num?: number;
|
||||
}
|
||||
|
||||
const KEYWORDS = new Set(['and', 'or', 'not']);
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const n = input.length;
|
||||
|
||||
while (i < n) {
|
||||
const c = input[i]!;
|
||||
|
||||
// whitespace
|
||||
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// parentheses
|
||||
if (c === '(') {
|
||||
tokens.push({ type: 'lparen', value: '(' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ')') {
|
||||
tokens.push({ type: 'rparen', value: ')' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// string literal — double or single quoted
|
||||
if (c === '"' || c === "'") {
|
||||
const quote = c;
|
||||
let j = i + 1;
|
||||
let str = '';
|
||||
while (j < n && input[j] !== quote) {
|
||||
if (input[j] === '\\' && j + 1 < n) {
|
||||
str += input[j + 1];
|
||||
j += 2;
|
||||
} else {
|
||||
str += input[j];
|
||||
j++;
|
||||
}
|
||||
}
|
||||
if (j >= n) throw new ExprError(`unterminated string literal in "${input}"`);
|
||||
tokens.push({ type: 'string', value: str });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// number literal
|
||||
if (isDigit(c) || (c === '.' && i + 1 < n && isDigit(input[i + 1]!))) {
|
||||
let j = i;
|
||||
while (j < n && (isDigit(input[j]!) || input[j] === '.')) j++;
|
||||
const raw = input.slice(i, j);
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num)) throw new ExprError(`invalid number "${raw}"`);
|
||||
tokens.push({ type: 'number', value: raw, num });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// multi-char comparison / single-char operators
|
||||
const two = input.slice(i, i + 2);
|
||||
if (two === '==' || two === '!=' || two === '<=' || two === '>=') {
|
||||
tokens.push({ type: 'op', value: two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '<' || c === '>' || c === '+' || c === '-' || c === '*' || c === '/') {
|
||||
tokens.push({ type: 'op', value: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '=' || c === '!') {
|
||||
// single '=' or '!' are not valid on their own in this grammar
|
||||
throw new ExprError(`unexpected '${c}' in "${input}"`);
|
||||
}
|
||||
|
||||
// identifier / keyword
|
||||
if (isIdentStart(c)) {
|
||||
let j = i;
|
||||
while (j < n && isIdentPart(input[j]!)) j++;
|
||||
const word = input.slice(i, j);
|
||||
if (KEYWORDS.has(word)) {
|
||||
tokens.push({ type: 'kw', value: word });
|
||||
} else {
|
||||
tokens.push({ type: 'ident', value: word });
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ExprError(`unexpected character '${c}' in "${input}"`);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isDigit(c: string): boolean {
|
||||
return c >= '0' && c <= '9';
|
||||
}
|
||||
function isIdentStart(c: string): boolean {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
|
||||
}
|
||||
function isIdentPart(c: string): boolean {
|
||||
return isIdentStart(c) || isDigit(c);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser (recursive descent) — parses & evaluates in one pass.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Parser {
|
||||
private pos = 0;
|
||||
constructor(
|
||||
private readonly tokens: Token[],
|
||||
private readonly scope: ExprScope,
|
||||
private readonly source: string,
|
||||
) {}
|
||||
|
||||
parse(): ExprValue {
|
||||
if (this.tokens.length === 0) {
|
||||
throw new ExprError(`empty expression "${this.source}"`);
|
||||
}
|
||||
const value = this.parseOr();
|
||||
if (this.pos < this.tokens.length) {
|
||||
throw new ExprError(
|
||||
`unexpected token '${this.peek()!.value}' after expression in "${this.source}"`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private peek(): Token | undefined {
|
||||
return this.tokens[this.pos];
|
||||
}
|
||||
|
||||
private next(): Token {
|
||||
const t = this.tokens[this.pos];
|
||||
if (!t) throw new ExprError(`unexpected end of expression in "${this.source}"`);
|
||||
this.pos++;
|
||||
return t;
|
||||
}
|
||||
|
||||
private parseOr(): ExprValue {
|
||||
let left = this.parseAnd();
|
||||
while (this.peek()?.type === 'kw' && this.peek()!.value === 'or') {
|
||||
this.next();
|
||||
const right = this.parseAnd();
|
||||
left = toBool(left) || toBool(right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseAnd(): ExprValue {
|
||||
let left = this.parseNot();
|
||||
while (this.peek()?.type === 'kw' && this.peek()!.value === 'and') {
|
||||
this.next();
|
||||
const right = this.parseNot();
|
||||
left = toBool(left) && toBool(right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseNot(): ExprValue {
|
||||
if (this.peek()?.type === 'kw' && this.peek()!.value === 'not') {
|
||||
this.next();
|
||||
return !toBool(this.parseNot());
|
||||
}
|
||||
return this.parseComparison();
|
||||
}
|
||||
|
||||
private parseComparison(): ExprValue {
|
||||
const left = this.parseAdd();
|
||||
const t = this.peek();
|
||||
if (
|
||||
t?.type === 'op' &&
|
||||
(t.value === '==' ||
|
||||
t.value === '!=' ||
|
||||
t.value === '<' ||
|
||||
t.value === '<=' ||
|
||||
t.value === '>' ||
|
||||
t.value === '>=')
|
||||
) {
|
||||
this.next();
|
||||
const right = this.parseAdd();
|
||||
return compare(t.value, left, right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseAdd(): ExprValue {
|
||||
let left = this.parseMul();
|
||||
while (this.peek()?.type === 'op' && (this.peek()!.value === '+' || this.peek()!.value === '-')) {
|
||||
const op = this.next().value;
|
||||
const right = this.parseMul();
|
||||
left = op === '+' ? toNum(left) + toNum(right) : toNum(left) - toNum(right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseMul(): ExprValue {
|
||||
let left = this.parseUnary();
|
||||
while (this.peek()?.type === 'op' && (this.peek()!.value === '*' || this.peek()!.value === '/')) {
|
||||
const op = this.next().value;
|
||||
const right = this.parseUnary();
|
||||
left = op === '*' ? toNum(left) * toNum(right) : toNum(left) / toNum(right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseUnary(): ExprValue {
|
||||
if (this.peek()?.type === 'op' && this.peek()!.value === '-') {
|
||||
this.next();
|
||||
return -toNum(this.parseUnary());
|
||||
}
|
||||
return this.parsePrimary();
|
||||
}
|
||||
|
||||
private parsePrimary(): ExprValue {
|
||||
const t = this.next();
|
||||
switch (t.type) {
|
||||
case 'number':
|
||||
return t.num!;
|
||||
case 'string':
|
||||
return t.value;
|
||||
case 'ident': {
|
||||
const v = this.scope[t.value];
|
||||
// Loose grammar: an unknown variable resolves to 0.
|
||||
return v === undefined ? 0 : v;
|
||||
}
|
||||
case 'lparen': {
|
||||
const v = this.parseOr();
|
||||
const close = this.peek();
|
||||
if (!close || close.type !== 'rparen') {
|
||||
throw new ExprError(`missing ')' in "${this.source}"`);
|
||||
}
|
||||
this.next();
|
||||
return v;
|
||||
}
|
||||
default:
|
||||
throw new ExprError(`unexpected token '${t.value}' in "${this.source}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value coercion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toBool(v: ExprValue): boolean {
|
||||
if (typeof v === 'boolean') return v;
|
||||
if (typeof v === 'number') return v !== 0;
|
||||
return v.length > 0 && v !== 'false' && v !== '0';
|
||||
}
|
||||
|
||||
function toNum(v: ExprValue): number {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function compare(op: string, left: ExprValue, right: ExprValue): boolean {
|
||||
// Equality is type-aware: compare strings as strings, numbers as numbers.
|
||||
if (op === '==' || op === '!=') {
|
||||
let eq: boolean;
|
||||
if (typeof left === 'string' || typeof right === 'string') {
|
||||
eq = String(left) === String(right);
|
||||
} else {
|
||||
eq = toNum(left) === toNum(right);
|
||||
}
|
||||
return op === '==' ? eq : !eq;
|
||||
}
|
||||
// Ordering comparisons are numeric.
|
||||
const l = toNum(left);
|
||||
const r = toNum(right);
|
||||
switch (op) {
|
||||
case '<':
|
||||
return l < r;
|
||||
case '<=':
|
||||
return l <= r;
|
||||
case '>':
|
||||
return l > r;
|
||||
case '>=':
|
||||
return l >= r;
|
||||
default:
|
||||
throw new ExprError(`unknown comparison operator '${op}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse and evaluate an expression, returning number | string | boolean. */
|
||||
export function evalExpr(expr: string, scope: ExprScope = {}): ExprValue {
|
||||
const tokens = tokenize(expr);
|
||||
return new Parser(tokens, scope, expr).parse();
|
||||
}
|
||||
|
||||
/** Evaluate and coerce to boolean (used for conditions / while-loops). */
|
||||
export function evalBool(expr: string, scope: ExprScope = {}): boolean {
|
||||
return toBool(evalExpr(expr, scope));
|
||||
}
|
||||
|
||||
/** Evaluate and coerce to number. */
|
||||
export function evalNumber(expr: string, scope: ExprScope = {}): number {
|
||||
return toNum(evalExpr(expr, scope));
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Firmware driver — drives the ESP32 master over HTTP.
|
||||
*
|
||||
* The firmware does NOT execute the scenario; it is piloted from the outside.
|
||||
* The runner uses this interface to: push the IR, arm a step's local puzzle,
|
||||
* poll the resolved state, and set the detected group profile.
|
||||
*
|
||||
* Endpoints (master):
|
||||
* POST /game/scenario — load the IR
|
||||
* POST /game/step — arm the puzzle for { step_id }
|
||||
* GET /game/puzzle_state — current resolution state
|
||||
* POST /game/group_profile — set the detected group profile
|
||||
*
|
||||
* `fetch` is injected for testability (no real network calls in tests).
|
||||
*/
|
||||
|
||||
import type { RuntimeIR } from './runtime-ir.js';
|
||||
|
||||
/** Snapshot returned by GET /game/puzzle_state. Extra fields tolerated. */
|
||||
export interface PuzzleState {
|
||||
/** True once the current step's puzzle is resolved. */
|
||||
solved: boolean;
|
||||
/** Code assembled so far from solved fragments. */
|
||||
assembled_code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Group profile payload for /game/group_profile. */
|
||||
export interface GroupProfilePayload {
|
||||
profile?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Abstraction the runner depends on; HTTP impl + test stubs both satisfy it. */
|
||||
export interface FirmwareDriver {
|
||||
pushScenario(ir: RuntimeIR): Promise<void>;
|
||||
armStep(stepId: string): Promise<void>;
|
||||
getPuzzleState(): Promise<PuzzleState>;
|
||||
setGroupProfile(profile: GroupProfilePayload): Promise<void>;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface HttpFirmwareDriverOptions {
|
||||
/** Injected fetch (defaults to global fetch). */
|
||||
fetch?: FetchLike;
|
||||
}
|
||||
|
||||
export class HttpFirmwareDriver implements FirmwareDriver {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetch: FetchLike;
|
||||
|
||||
constructor(baseUrl: string, options: HttpFirmwareDriverOptions = {}) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
const injected = options.fetch;
|
||||
if (injected) {
|
||||
this.fetch = injected;
|
||||
} else if (typeof globalThis.fetch === 'function') {
|
||||
this.fetch = globalThis.fetch.bind(globalThis);
|
||||
} else {
|
||||
throw new Error('HttpFirmwareDriver: no fetch available; inject one via options.fetch');
|
||||
}
|
||||
}
|
||||
|
||||
async pushScenario(ir: RuntimeIR): Promise<void> {
|
||||
await this.post('/game/scenario', ir);
|
||||
}
|
||||
|
||||
async armStep(stepId: string): Promise<void> {
|
||||
await this.post('/game/step', { step_id: stepId });
|
||||
}
|
||||
|
||||
async getPuzzleState(): Promise<PuzzleState> {
|
||||
const res = await this.fetch(`${this.baseUrl}/game/puzzle_state`, { method: 'GET' });
|
||||
this.assertOk(res, 'GET /game/puzzle_state');
|
||||
return (await res.json()) as PuzzleState;
|
||||
}
|
||||
|
||||
async setGroupProfile(profile: GroupProfilePayload): Promise<void> {
|
||||
await this.post('/game/group_profile', profile);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async post(path: string, body: unknown): Promise<Response> {
|
||||
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
this.assertOk(res, `POST ${path}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
private assertOk(res: Response, what: string): void {
|
||||
if (!res.ok) {
|
||||
throw new Error(`firmware ${what} failed: HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,34 @@ export { detectGroupProfile, profileConfidence } from './profiler.js';
|
||||
export { computeScore, assembleCode } from './scorer.js';
|
||||
// Re-export the interface so consumers can type-check against it
|
||||
export type { ScenarioEngine } from '@zacus/shared';
|
||||
|
||||
// Runtime 3 runner — makes the IR playable (expressions, loops, transitions).
|
||||
export { evalExpr, evalBool, evalNumber, ExprError } from './expr-eval.js';
|
||||
export type { ExprScope, ExprValue } from './expr-eval.js';
|
||||
|
||||
export { ScenarioRunner } from './runner.js';
|
||||
export type { RunnerOptions } from './runner.js';
|
||||
|
||||
export { HttpFirmwareDriver } from './firmware-driver.js';
|
||||
export type {
|
||||
FirmwareDriver,
|
||||
PuzzleState,
|
||||
GroupProfilePayload,
|
||||
HttpFirmwareDriverOptions,
|
||||
} from './firmware-driver.js';
|
||||
|
||||
export type {
|
||||
RuntimeIR,
|
||||
RuntimeScenarioMeta,
|
||||
RuntimeStep,
|
||||
RuntimeTransition,
|
||||
RuntimeAction,
|
||||
ActionObject,
|
||||
ScoreAddAction,
|
||||
SetVarAction,
|
||||
ConditionAction,
|
||||
LoopAction,
|
||||
StepPuzzle,
|
||||
StepScene,
|
||||
} from './runtime-ir.js';
|
||||
export { isActionObject, actionKind } from './runtime-ir.js';
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* ScenarioRunner — makes a Runtime 3 IR actually *playable*.
|
||||
*
|
||||
* Unlike the legacy `ZacusScenarioEngine` (a phase/score simulator that left
|
||||
* conditions as opaque strings), the runner:
|
||||
* - executes each step's action list, including the `condition` (then/else)
|
||||
* and `loop` (repeat/while) control kinds, evaluating their `expr` with the
|
||||
* loose-grammar evaluator;
|
||||
* - maintains a variable scope (incl. `score`) mutated by `score_add` /
|
||||
* `set_var`, and used to evaluate transition conditions;
|
||||
* - resolves the next step from a step's transitions, honouring `branch` /
|
||||
* `condition` transition `expr` and `priority`;
|
||||
* - when a `FirmwareDriver` is provided, pilots the master live: pushes the
|
||||
* IR, arms each step, and polls `puzzle_state` until the step is resolved
|
||||
* before advancing.
|
||||
*
|
||||
* Non-control actions (tts_say, hw_servo, …) are forwarded to an `onAction`
|
||||
* sink so a host (firmware adapter, simulator UI) can realise them.
|
||||
*/
|
||||
|
||||
import { evalBool, evalNumber, type ExprScope } from './expr-eval.js';
|
||||
import {
|
||||
type RuntimeIR,
|
||||
type RuntimeStep,
|
||||
type RuntimeAction,
|
||||
type RuntimeTransition,
|
||||
isActionObject,
|
||||
} from './runtime-ir.js';
|
||||
import type { FirmwareDriver, GroupProfilePayload } from './firmware-driver.js';
|
||||
|
||||
export interface RunnerOptions {
|
||||
/** Live firmware driver. If absent, the runner executes logic only. */
|
||||
driver?: FirmwareDriver;
|
||||
/** Sink for non-control actions (tts_say, hw_servo, led_pattern, …). */
|
||||
onAction?: (action: RuntimeAction, scope: Readonly<ExprScope>) => void;
|
||||
/** Upper bound on loop iterations to prevent runaway while-loops. */
|
||||
maxLoopIterations?: number;
|
||||
/** Poll interval (ms) when waiting for a puzzle to resolve. */
|
||||
pollIntervalMs?: number;
|
||||
/** Max polls before giving up waiting for resolution (0 = unbounded). */
|
||||
maxPolls?: number;
|
||||
/** Injected sleep, for testability. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_LOOP_ITERATIONS = 10_000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
|
||||
interface ResolvedOptions {
|
||||
driver?: FirmwareDriver;
|
||||
onAction?: (action: RuntimeAction, scope: Readonly<ExprScope>) => void;
|
||||
maxLoopIterations: number;
|
||||
pollIntervalMs: number;
|
||||
maxPolls: number;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ScenarioRunner {
|
||||
private readonly ir: RuntimeIR;
|
||||
private readonly steps: Map<string, RuntimeStep>;
|
||||
private readonly opts: ResolvedOptions;
|
||||
private scope: ExprScope = { score: 0 };
|
||||
private current: string;
|
||||
|
||||
constructor(ir: RuntimeIR, options: RunnerOptions = {}) {
|
||||
this.ir = ir;
|
||||
this.steps = new Map(ir.steps.map((s) => [s.id, s]));
|
||||
this.current = ir.scenario.entry_step_id;
|
||||
this.opts = {
|
||||
maxLoopIterations: options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS,
|
||||
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
||||
maxPolls: options.maxPolls ?? 0,
|
||||
sleep: options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))),
|
||||
};
|
||||
if (options.driver) this.opts.driver = options.driver;
|
||||
if (options.onAction) this.opts.onAction = options.onAction;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scope ----
|
||||
|
||||
getVar(name: string): number | string | boolean | undefined {
|
||||
return this.scope[name];
|
||||
}
|
||||
|
||||
setVar(name: string, value: number | string | boolean): void {
|
||||
this.scope[name] = value;
|
||||
}
|
||||
|
||||
getScope(): Readonly<ExprScope> {
|
||||
return { ...this.scope };
|
||||
}
|
||||
|
||||
currentStepId(): string {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- execution ----
|
||||
|
||||
/** Execute all actions of a step (logic only — no firmware/transition). */
|
||||
runStep(stepId: string): void {
|
||||
const step = this.requireStep(stepId);
|
||||
this.runActions(step.actions ?? []);
|
||||
}
|
||||
|
||||
private runActions(actions: RuntimeAction[]): void {
|
||||
for (const action of actions) {
|
||||
this.runAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
private runAction(action: RuntimeAction): void {
|
||||
if (!isActionObject(action)) {
|
||||
// String-form action (e.g. "led_pattern:rainbow") — opaque, forward it.
|
||||
this.opts.onAction?.(action, this.scope);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.kind) {
|
||||
case 'score_add': {
|
||||
const delta = this.numField(action['delta']);
|
||||
this.scope['score'] = this.numScope('score') + delta;
|
||||
return;
|
||||
}
|
||||
case 'set_var': {
|
||||
const name = String(action['name'] ?? '');
|
||||
if (name) this.scope[name] = String(action['value'] ?? '');
|
||||
return;
|
||||
}
|
||||
case 'condition': {
|
||||
const expr = String(action['expr'] ?? '');
|
||||
const branch = this.evalBoolSafe(expr)
|
||||
? (action['then'] as RuntimeAction[] | undefined)
|
||||
: (action['else'] as RuntimeAction[] | undefined);
|
||||
if (Array.isArray(branch)) this.runActions(branch);
|
||||
return;
|
||||
}
|
||||
case 'loop': {
|
||||
this.runLoop(action);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Non-control action: forward to the host sink.
|
||||
this.opts.onAction?.(action, this.scope);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private runLoop(action: { [key: string]: unknown }): void {
|
||||
const mode = action['mode'];
|
||||
const body = Array.isArray(action['body']) ? (action['body'] as RuntimeAction[]) : [];
|
||||
const limit = this.opts.maxLoopIterations;
|
||||
|
||||
if (mode === 'repeat') {
|
||||
const times = Math.max(0, Math.floor(this.numField(action['times'])));
|
||||
const n = Math.min(times, limit);
|
||||
for (let i = 0; i < n; i++) this.runActions(body);
|
||||
if (times > limit) {
|
||||
throw new Error(`loop iteration limit exceeded (${limit}) for repeat loop`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'while') {
|
||||
const expr = String(action['expr'] ?? '');
|
||||
let i = 0;
|
||||
while (this.evalBoolSafe(expr)) {
|
||||
if (i >= limit) {
|
||||
throw new Error(`loop iteration limit exceeded (${limit}) for while loop`);
|
||||
}
|
||||
this.runActions(body);
|
||||
i++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown loop mode '${String(mode)}'`);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- transitions ----
|
||||
|
||||
/**
|
||||
* Resolve the next step id from a step's transitions.
|
||||
* Branch/condition transitions are taken only when their `expr` is truthy
|
||||
* (a transition with no `expr` is always eligible). Lower `priority` wins.
|
||||
* Returns undefined when no transition applies (terminal step).
|
||||
*/
|
||||
nextStep(stepId: string): string | undefined {
|
||||
const step = this.requireStep(stepId);
|
||||
const transitions = [...(step.transitions ?? [])].sort(
|
||||
(a, b) => (a.priority ?? 0) - (b.priority ?? 0),
|
||||
);
|
||||
for (const t of transitions) {
|
||||
if (this.transitionEligible(t)) return t.target_step_id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private transitionEligible(t: RuntimeTransition): boolean {
|
||||
if (typeof t.expr === 'string' && t.expr.length > 0) {
|
||||
return this.evalBoolSafe(t.expr);
|
||||
}
|
||||
// branch/condition transitions without an expr act as the default fallback;
|
||||
// all other event types are eligible (firmware/timer gates them at advance).
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- live firmware ----
|
||||
|
||||
/** Push the IR to the firmware and arm the entry step. */
|
||||
async start(): Promise<void> {
|
||||
this.current = this.ir.scenario.entry_step_id;
|
||||
if (this.opts.driver) {
|
||||
await this.opts.driver.pushScenario(this.ir);
|
||||
}
|
||||
await this.enterStep(this.current);
|
||||
}
|
||||
|
||||
/** Run actions for a step and (if live) arm its puzzle on the firmware. */
|
||||
private async enterStep(stepId: string): Promise<void> {
|
||||
this.current = stepId;
|
||||
this.runStep(stepId);
|
||||
if (this.opts.driver) {
|
||||
await this.opts.driver.armStep(stepId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance from the current step: wait for the firmware to resolve the step's
|
||||
* puzzle (polling `puzzle_state`), then move to the next step (entering it).
|
||||
* Returns the new step id, or undefined when the scenario is terminal.
|
||||
*/
|
||||
async advance(): Promise<string | undefined> {
|
||||
const step = this.requireStep(this.current);
|
||||
if (this.opts.driver && step.puzzle) {
|
||||
await this.waitForResolution();
|
||||
}
|
||||
const next = this.nextStep(this.current);
|
||||
if (next === undefined) return undefined;
|
||||
await this.enterStep(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private async waitForResolution(): Promise<void> {
|
||||
const driver = this.opts.driver!;
|
||||
let polls = 0;
|
||||
for (;;) {
|
||||
const state = await driver.getPuzzleState();
|
||||
if (state.solved) {
|
||||
if (typeof state.assembled_code === 'string') {
|
||||
this.scope['assembled_code'] = state.assembled_code;
|
||||
}
|
||||
return;
|
||||
}
|
||||
polls++;
|
||||
if (this.opts.maxPolls > 0 && polls >= this.opts.maxPolls) {
|
||||
throw new Error(`puzzle_state not resolved after ${polls} polls`);
|
||||
}
|
||||
await this.opts.sleep(this.opts.pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forward a detected group profile to the firmware. */
|
||||
async setGroupProfile(profile: GroupProfilePayload): Promise<void> {
|
||||
if (this.opts.driver) await this.opts.driver.setGroupProfile(profile);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- utils ----
|
||||
|
||||
private requireStep(stepId: string): RuntimeStep {
|
||||
const step = this.steps.get(stepId);
|
||||
if (!step) throw new Error(`unknown step '${stepId}'`);
|
||||
return step;
|
||||
}
|
||||
|
||||
private evalBoolSafe(expr: string): boolean {
|
||||
if (!expr) return false;
|
||||
return evalBool(expr, this.scope);
|
||||
}
|
||||
|
||||
private numField(v: unknown): number {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string') {
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? evalNumber(v, this.scope) : n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private numScope(name: string): number {
|
||||
const v = this.scope[name];
|
||||
return typeof v === 'number' ? v : v === undefined ? 0 : Number(v) || 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* TypeScript model of the Zacus Runtime 3 IR (`zacus.runtime3.v1`).
|
||||
*
|
||||
* Mirrors the document produced by the Python compiler
|
||||
* (`tools/scenario/runtime3_common.py`) and consumed by the ESP32 master
|
||||
* firmware. Only the parts the runner needs are typed precisely; unknown
|
||||
* fields are tolerated (the IR is passed through verbatim by the compiler).
|
||||
*
|
||||
* See `specs/ZACUS_RUNTIME_3_SPEC.md`.
|
||||
*/
|
||||
|
||||
export interface RuntimeIR {
|
||||
schema_version: string; // "zacus.runtime3.v1"
|
||||
scenario: RuntimeScenarioMeta;
|
||||
steps: RuntimeStep[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuntimeScenarioMeta {
|
||||
id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
entry_step_id: string;
|
||||
source_kind?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeStep {
|
||||
id: string;
|
||||
scene_id?: string;
|
||||
audio_pack_id?: string;
|
||||
actions?: RuntimeAction[];
|
||||
apps?: unknown[];
|
||||
transitions?: RuntimeTransition[];
|
||||
narrative?: string;
|
||||
/** Master-local puzzle armed on step entry (P1 sound / P3 QR). */
|
||||
puzzle?: StepPuzzle;
|
||||
/** Master display metadata. */
|
||||
scene?: StepScene;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `event_type` is an opaque runtime contract. The runner understands:
|
||||
* - `branch` / `condition`: pick target when `expr` is truthy (see runner)
|
||||
* - `timer`: fires after `after_ms`
|
||||
* - `action` with `event_name` `goto:<target>`: explicit jump
|
||||
* - everything else (`button`, `serial`, `unlock`, `espnow`, `audio_done`):
|
||||
* advanced when the firmware reports the step resolved.
|
||||
*/
|
||||
export interface RuntimeTransition {
|
||||
event_type: string;
|
||||
event_name?: string;
|
||||
target_step_id: string;
|
||||
priority?: number;
|
||||
after_ms?: number;
|
||||
/** Loose-grammar condition. Present on `branch` / `condition` transitions. */
|
||||
expr?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dict-form action. String-form (`"led_pattern:rainbow"`) is also accepted. */
|
||||
export type RuntimeAction = ActionObject | string;
|
||||
|
||||
export interface ActionObject {
|
||||
kind: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ScoreAddAction extends ActionObject {
|
||||
kind: 'score_add';
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface SetVarAction extends ActionObject {
|
||||
kind: 'set_var';
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ConditionAction extends ActionObject {
|
||||
kind: 'condition';
|
||||
expr: string;
|
||||
then?: RuntimeAction[];
|
||||
else?: RuntimeAction[];
|
||||
}
|
||||
|
||||
/** Vague 1: bounded `repeat` (times) / `while` (expr) loop with a nested body. */
|
||||
export interface LoopAction extends ActionObject {
|
||||
kind: 'loop';
|
||||
mode: 'repeat' | 'while';
|
||||
times?: number; // repeat mode
|
||||
expr?: string; // while mode
|
||||
body?: RuntimeAction[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step extras
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StepPuzzle {
|
||||
id: number;
|
||||
type: 'qr' | 'sound';
|
||||
codes?: string[];
|
||||
melody?: number[];
|
||||
tolerance?: number;
|
||||
fragment: number[];
|
||||
scene?: string;
|
||||
}
|
||||
|
||||
export interface StepScene {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
symbol?: string;
|
||||
effect?: 'pulse' | 'glitch' | 'gyro' | 'none';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isActionObject(a: RuntimeAction): a is ActionObject {
|
||||
return typeof a === 'object' && a !== null && typeof a.kind === 'string';
|
||||
}
|
||||
|
||||
export function actionKind(a: RuntimeAction): string {
|
||||
if (typeof a === 'string') {
|
||||
// String form: "led_pattern:rainbow" -> kind "led_pattern".
|
||||
const idx = a.indexOf(':');
|
||||
return idx >= 0 ? a.slice(0, idx) : a;
|
||||
}
|
||||
return a.kind;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Hand-written types for the canonical Blockly catalog (plain .mjs so the
|
||||
// hub's classic-script copy can be generated from the same source).
|
||||
|
||||
export declare const ZACUS_COLOR: Record<string, string>;
|
||||
export declare const ZACUS_VOICES: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_EMOTIONS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_SCENE_EFFECTS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_COMPARE_OPS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_BOOL_OPS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_ARITH_OPS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_PHASE_TRIGGERS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_NPC_MOODS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_GM_ACTIONS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_PENALTY_KINDS: ReadonlyArray<[string, string]>;
|
||||
export declare const ZACUS_MORSE_MODES: ReadonlyArray<[string, string]>;
|
||||
|
||||
/** Register every zacus_* block shape against the given Blockly module. */
|
||||
export declare function registerZacusBlocks(blockly: unknown): void;
|
||||
|
||||
export declare const ZACUS_BLOCK_TYPES: readonly string[];
|
||||
export declare const ZACUS_FIELDS_BY_KIND: Readonly<Record<string, readonly string[]>>;
|
||||
|
||||
export interface ZacusToolboxCategory {
|
||||
name: string;
|
||||
colour: string;
|
||||
types: readonly string[];
|
||||
}
|
||||
export declare const ZACUS_TOOLBOX_CATEGORIES: readonly ZacusToolboxCategory[];
|
||||
@@ -0,0 +1,847 @@
|
||||
// Canonical Zacus Blockly catalog — single source of truth for BOTH editors:
|
||||
// - Atelier webapp: import { registerZacusBlocks } from '@zacus/shared/blockly'
|
||||
// - zacus-hub WebView: apps/zacus-hub/Resources/blockly/zacus_blocks.js is
|
||||
// GENERATED from this file by tools/dev/sync_blockly_catalog.sh (strips
|
||||
// `export` keywords and appends the legacy window.* globals + auto-register).
|
||||
// Never edit the hub copy by hand.
|
||||
//
|
||||
// Block kinds match tools/zacus-gateway/blocks_to_runtime3.py (without the
|
||||
// `zacus_` prefix used for the Blockly type names). Shapes follow Scratch
|
||||
// conventions: hat (sceneStart), cap (sceneEnd), statement (most), c-block
|
||||
// (logicIf with "alors"/"sinon" slots).
|
||||
|
||||
export const ZACUS_COLOR = {
|
||||
scene: "#5085F2",
|
||||
npc: "#D85B73",
|
||||
audio: "#A772D9",
|
||||
lcd: "#338CBF",
|
||||
hardware: "#2EAD8C",
|
||||
espnow: "#F28033",
|
||||
box3: "#8C5A33",
|
||||
m5: "#4D73A6",
|
||||
plip: "#C74D8C",
|
||||
logic: "#F2A633",
|
||||
puzzle: "#59A86C",
|
||||
operator: "#59A659",
|
||||
control: "#D9A032",
|
||||
gm: "#7A5CCC",
|
||||
};
|
||||
|
||||
// Vague 1 — opérateurs de comparaison (sérialisés en string d'expression
|
||||
// dans la grammaire « lâche » déjà utilisée par les conditions du scénario,
|
||||
// ex `score > 5`). value = symbole émis dans la string.
|
||||
export const ZACUS_COMPARE_OPS = [
|
||||
["=", "=="], ["≠", "!="], ["<", "<"], ["≤", "<="], [">", ">"], ["≥", ">="],
|
||||
];
|
||||
export const ZACUS_BOOL_OPS = [
|
||||
["et", "and"], ["ou", "or"],
|
||||
];
|
||||
export const ZACUS_ARITH_OPS = [
|
||||
["+", "+"], ["−", "-"], ["×", "*"], ["÷", "/"],
|
||||
];
|
||||
|
||||
// Effets d'affichage de la vue scène du maître (puzzle_binding.h).
|
||||
export const ZACUS_SCENE_EFFECTS = [
|
||||
["pulse", "pulse"],
|
||||
["glitch", "glitch"],
|
||||
["gyro", "gyro"],
|
||||
["aucun", "none"],
|
||||
];
|
||||
|
||||
// « Zacus dit » voice options. `voice` maps to the voice-bridge `voice_ref`
|
||||
// (reference clip for F5-TTS voice cloning); emotion + rate are stored in the
|
||||
// IR (tts_say) as forward-compat fields — the bridge ignores them until it
|
||||
// learns them.
|
||||
export const ZACUS_VOICES = [
|
||||
["Zacus", "zacus"],
|
||||
["Narrateur", "narrateur"],
|
||||
["Assistant", "assistant"],
|
||||
];
|
||||
export const ZACUS_EMOTIONS = [
|
||||
["neutre", "neutral"],
|
||||
["joyeux", "happy"],
|
||||
["inquiet", "worried"],
|
||||
["mystérieux", "mysterious"],
|
||||
["pressé", "urgent"],
|
||||
];
|
||||
|
||||
// ── Vague 2 : couche « Maître du jeu » (game-master adaptatif) ─────────
|
||||
// Vocabulaire repris de game/scenarios/zacus_v3_complete.yaml (phases,
|
||||
// npc_mood_rules, hint_escalation, scoring conditionnel, group_classification).
|
||||
|
||||
// Déclencheurs de phase (phases.*.trigger). game_start est le seul présent
|
||||
// dans le scénario ; les autres couvrent l'enchaînement profiling→outro.
|
||||
export const ZACUS_PHASE_TRIGGERS = [
|
||||
["démarrage partie", "game_start"],
|
||||
["phase précédente finie", "phase_done"],
|
||||
["profilage terminé", "profiling_done"],
|
||||
["manuel", "manual"],
|
||||
];
|
||||
|
||||
// Humeurs NPC adaptatives (npc_mood_rules.*.mood).
|
||||
export const ZACUS_NPC_MOODS = [
|
||||
["neutre", "NEUTRAL"],
|
||||
["impressionné", "IMPRESSED"],
|
||||
["inquiet", "WORRIED"],
|
||||
["pressé", "URGENT"],
|
||||
["amusé", "AMUSED"],
|
||||
];
|
||||
|
||||
// Actions adaptatives (npc_mood_rules.*.action + game-master loop).
|
||||
export const ZACUS_GM_ACTIONS = [
|
||||
["indice proactif", "proactive_hint"],
|
||||
["passer au suivant", "skip_to_next"],
|
||||
["ajouter énigme bonus", "add_bonus_puzzle"],
|
||||
];
|
||||
|
||||
// Types de pénalité de score (scoring.*).
|
||||
export const ZACUS_PENALTY_KINDS = [
|
||||
["par minute", "time_penalty_per_minute"],
|
||||
["par indice", "hint_penalty"],
|
||||
];
|
||||
|
||||
// ── Vagues 3 & 4 : énigmes manquantes + assemblage du code final ──────
|
||||
// Vocabulaire repris de game/scenarios/zacus_v3_complete.yaml :
|
||||
// - P2 Circuit LED, P4 Fréquence Radio, P5 Code Morse, P6 Symboles NFC,
|
||||
// P7 Coffre Final (puzzles.*),
|
||||
// - phases.climax.code_assembly (mapping puzzle→position de chiffres).
|
||||
|
||||
// Mode du décodage morse (P5.difficulty_variants : full_morse_decode vs
|
||||
// morse_light_mode où l'émission est lumineuse au lieu d'être sonore).
|
||||
export const ZACUS_MORSE_MODES = [
|
||||
["sonore (normal)", "normal"],
|
||||
["lumineux (light)", "light"],
|
||||
];
|
||||
|
||||
// ── Phase D2 : binding carte ──────────────────────────────────────────
|
||||
// Cartes connues = clés de tools/zacus-gateway/boards.yaml. Le champ `board`
|
||||
// (défaut `master`) sur les énigmes réelles et le bloc contexte `hwOnBoard`
|
||||
// ciblent l'exécution sur une carte précise. Garde cette liste en miroir de
|
||||
// VALID_BOARDS côté Python (tools/scenario/runtime3_common.py) et de boards.yaml.
|
||||
export const ZACUS_BOARDS = [
|
||||
["Master", "master"],
|
||||
["BOX-3", "box3"],
|
||||
["PLIP", "plip"],
|
||||
["Coffre P7", "p7_coffre"],
|
||||
];
|
||||
|
||||
export function registerZacusBlocks(Blockly) {
|
||||
function def(name, init) { Blockly.Blocks[name] = { init: init }; }
|
||||
function stack(t, color) { t.setPreviousStatement(true, null); t.setNextStatement(true, null); t.setColour(color); }
|
||||
function dropdown(opts) {
|
||||
// accept [["label","value"], …] or ["value", …]
|
||||
return new Blockly.FieldDropdown(opts.map(o => Array.isArray(o) ? o : [o, o]));
|
||||
}
|
||||
// Blockly >=10 ships FieldMultilineInput as a plugin, not core. Fall back to
|
||||
// a plain text input so the catalog works against both bundles.
|
||||
const MultilineInput = Blockly.FieldMultilineInput || Blockly.FieldTextInput;
|
||||
|
||||
// Scènes
|
||||
def("zacus_sceneStart", function () {
|
||||
this.appendDummyInput().appendField("▶ Début de scène").appendField(new Blockly.FieldTextInput("intro"), "id");
|
||||
this.setNextStatement(true, null); this.setColour(ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_sceneEnd", function () {
|
||||
this.appendDummyInput().appendField("⏹ Fin de scène");
|
||||
this.setPreviousStatement(true, null); this.setColour(ZACUS_COLOR.scene);
|
||||
});
|
||||
def("zacus_sceneGoto", function () {
|
||||
this.appendDummyInput().appendField("Aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_sceneBranch", function () {
|
||||
// condition : soit un reporter branché (appendValueInput "condition"),
|
||||
// soit le champ texte de repli (rétrocompat strings opaques).
|
||||
this.appendValueInput("condition").setCheck(null).appendField("Branche si");
|
||||
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("score > 5"), "condition");
|
||||
this.appendDummyInput().appendField("alors").appendField(new Blockly.FieldTextInput("scene_a"), "ifTrue");
|
||||
this.appendDummyInput().appendField("sinon").appendField(new Blockly.FieldTextInput("scene_b"), "ifFalse");
|
||||
stack(this, ZACUS_COLOR.scene);
|
||||
});
|
||||
|
||||
// NPC — « Zacus dit » porte la voix (voice_ref), l'émotion et la vitesse.
|
||||
def("zacus_npcSay", function () {
|
||||
this.appendDummyInput().appendField("Zacus dit")
|
||||
.appendField(new MultilineInput("Bonjour, je suis le professeur Zacus."), "text");
|
||||
this.appendDummyInput().appendField("voix").appendField(dropdown(ZACUS_VOICES), "voice")
|
||||
.appendField("émotion").appendField(dropdown(ZACUS_EMOTIONS), "emotion")
|
||||
.appendField("vitesse").appendField(new Blockly.FieldNumber(1, 0.5, 2, 0.1), "rate");
|
||||
stack(this, ZACUS_COLOR.npc);
|
||||
});
|
||||
def("zacus_npcWaitResponse", function () {
|
||||
this.appendDummyInput().appendField("Attendre réponse — timeout")
|
||||
.appendField(new Blockly.FieldNumber(10, 1, 120, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.npc); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_npcIntentMatch", function () {
|
||||
this.appendDummyInput().appendField("Si intent =").appendField(new Blockly.FieldTextInput("yes"), "intent");
|
||||
this.appendDummyInput().appendField("alors aller à").appendField(new Blockly.FieldTextInput("scene_x"), "then");
|
||||
stack(this, ZACUS_COLOR.npc);
|
||||
});
|
||||
|
||||
// Audio
|
||||
def("zacus_hwSoundPlay", function () {
|
||||
this.appendDummyInput().appendField("🔊 Jouer son").appendField(new Blockly.FieldTextInput("sting_win"), "asset");
|
||||
stack(this, ZACUS_COLOR.audio); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwAudioStop", function () {
|
||||
this.appendDummyInput().appendField("⏸ Stopper l'audio");
|
||||
stack(this, ZACUS_COLOR.audio);
|
||||
});
|
||||
def("zacus_hwAudioVolume", function () {
|
||||
this.appendDummyInput().appendField("Volume")
|
||||
.appendField(new Blockly.FieldNumber(70, 0, 100, 1), "level").appendField("/100");
|
||||
stack(this, ZACUS_COLOR.audio); this.setInputsInline(true);
|
||||
});
|
||||
// Phase D3 — média réel SD : joue un fichier par CHEMIN SD réel (ex.
|
||||
// zacus/<board>/<file>.wav), résolu par le firmware (/sdcard/… ou
|
||||
// /littlefs/…). Le `board` vient du contexte hwOnBoard (défaut master),
|
||||
// PAS d'un champ ici. Compile → action sound_play{path, board, loop}. Le
|
||||
// legacy zacus_hwSoundPlay(asset opaque) reste inchangé.
|
||||
def("zacus_mediaPlay", function () {
|
||||
this.appendDummyInput().appendField("♫ Jouer média")
|
||||
.appendField(new Blockly.FieldTextInput("zacus/master/intro.wav"), "path");
|
||||
this.appendDummyInput().appendField("boucle").appendField(new Blockly.FieldCheckbox("FALSE"), "loop");
|
||||
stack(this, ZACUS_COLOR.audio); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// LCD
|
||||
def("zacus_hwLCDText", function () {
|
||||
this.appendDummyInput().appendField("LCD ligne")
|
||||
.appendField(new Blockly.FieldNumber(0, 0, 32, 1), "line")
|
||||
.appendField("texte").appendField(new Blockly.FieldTextInput("Bienvenue"), "text");
|
||||
stack(this, ZACUS_COLOR.lcd); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwLCDClear", function () {
|
||||
this.appendDummyInput().appendField("LCD — effacer");
|
||||
stack(this, ZACUS_COLOR.lcd);
|
||||
});
|
||||
def("zacus_hwLCDImage", function () {
|
||||
this.appendDummyInput().appendField("LCD image").appendField(new Blockly.FieldTextInput("splash"), "asset");
|
||||
stack(this, ZACUS_COLOR.lcd); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwLCDTouchWait", function () {
|
||||
this.appendDummyInput().appendField("Attendre tap zone").appendField(new Blockly.FieldTextInput("btn_yes"), "zone")
|
||||
.appendField("timeout").appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.lcd); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// Hardware (ESP32 générique)
|
||||
def("zacus_hwServo", function () {
|
||||
this.appendDummyInput().appendField("Servo canal")
|
||||
.appendField(new Blockly.FieldNumber(0, 0, 15, 1), "channel")
|
||||
.appendField("→ angle").appendField(new Blockly.FieldNumber(90, 0, 180, 1), "angle").appendField("°");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwReadQR", function () {
|
||||
this.appendDummyInput().appendField("QR — attendu").appendField(new Blockly.FieldTextInput("ZAC-A1"), "expected");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwLEDPattern", function () {
|
||||
this.appendDummyInput().appendField("LED motif").appendField(dropdown([
|
||||
["arc-en-ciel","rainbow"],["clignote","blink"],["fondu","fade"],["éteindre","off"]
|
||||
]), "pattern");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwBuzzerTone", function () {
|
||||
this.appendDummyInput().appendField("Buzzer")
|
||||
.appendField(new Blockly.FieldNumber(2000, 20, 20000, 1), "freq").appendField("Hz pendant")
|
||||
.appendField(new Blockly.FieldNumber(120, 1, 10000, 1), "ms").appendField("ms");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwRelay", function () {
|
||||
this.appendDummyInput().appendField("Relais canal")
|
||||
.appendField(new Blockly.FieldNumber(0, 0, 15, 1), "channel")
|
||||
.appendField(dropdown([["allumer","on"],["éteindre","off"],["impulsion","pulse"]]), "state");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwSensorRead", function () {
|
||||
this.appendDummyInput().appendField("Lire capteur").appendField(new Blockly.FieldTextInput("A0"), "pin")
|
||||
.appendField("→").appendField(new Blockly.FieldTextInput("lecture"), "var");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_hwButtonWait", function () {
|
||||
this.appendDummyInput().appendField("Attendre bouton").appendField(new Blockly.FieldTextInput("btn_main"), "button")
|
||||
.appendField("timeout").appendField(new Blockly.FieldNumber(0, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// ESP-NOW
|
||||
def("zacus_espnowRegisterPeer", function () {
|
||||
this.appendDummyInput().appendField("ESP-NOW alias").appendField(new Blockly.FieldTextInput("annexe1"), "alias")
|
||||
.appendField("MAC").appendField(new Blockly.FieldTextInput("AA:BB:CC:DD:EE:FF"), "mac");
|
||||
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_espnowSend", function () {
|
||||
this.appendDummyInput().appendField("ESP-NOW → peer").appendField(new Blockly.FieldTextInput("annexe1"), "peer")
|
||||
.appendField("cmd").appendField(new Blockly.FieldTextInput("open_door"), "command");
|
||||
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_espnowBroadcast", function () {
|
||||
this.appendDummyInput().appendField("ESP-NOW broadcast").appendField(new Blockly.FieldTextInput("reset"), "command");
|
||||
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_espnowWait", function () {
|
||||
this.appendDummyInput().appendField("ESP-NOW attendre").appendField(new Blockly.FieldTextInput("ready"), "command")
|
||||
.appendField("timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.espnow); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// ESP32-S3-BOX-3
|
||||
def("zacus_boxIMUShake", function () {
|
||||
this.appendDummyInput().appendField("BOX-3 secousse seuil")
|
||||
.appendField(new Blockly.FieldNumber(1.5, 0.1, 10, 0.1), "threshold")
|
||||
.appendField("g timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.box3); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_boxIRSend", function () {
|
||||
this.appendDummyInput().appendField("BOX-3 IR").appendField(dropdown(["NEC","RC5","SONY","RAW"]), "protocol")
|
||||
.appendField(new Blockly.FieldTextInput("0x20DF10EF"), "code");
|
||||
stack(this, ZACUS_COLOR.box3); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// M5Stack / M5StickC
|
||||
def("zacus_m5Beep", function () {
|
||||
this.appendDummyInput().appendField("M5 bip")
|
||||
.appendField(new Blockly.FieldNumber(4000, 20, 20000, 1), "freq").appendField("Hz")
|
||||
.appendField(new Blockly.FieldNumber(200, 1, 5000, 1), "ms").appendField("ms");
|
||||
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_m5LCDText", function () {
|
||||
this.appendDummyInput().appendField("M5 LCD").appendField(new Blockly.FieldTextInput("Hello"), "text")
|
||||
.appendField(dropdown(["white","yellow","red","green","blue","cyan","magenta"]), "color")
|
||||
.appendField("size").appendField(new Blockly.FieldNumber(2, 1, 8, 1), "size");
|
||||
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_m5ButtonAB", function () {
|
||||
this.appendDummyInput().appendField("M5 bouton").appendField(dropdown(["A","B","C","any"]), "button")
|
||||
.appendField("timeout").appendField(new Blockly.FieldNumber(0, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_m5RGBLed", function () {
|
||||
this.appendDummyInput().appendField("M5 LED RGB").appendField(new Blockly.FieldTextInput("#FF8800"), "color");
|
||||
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_m5IMUShake", function () {
|
||||
this.appendDummyInput().appendField("M5 secousse seuil")
|
||||
.appendField(new Blockly.FieldNumber(1.5, 0.1, 10, 0.1), "threshold")
|
||||
.appendField("g timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.m5); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// PLIP téléphone
|
||||
def("zacus_plipRing", function () {
|
||||
this.appendDummyInput().appendField("☎ PLIP sonner")
|
||||
.appendField(new Blockly.FieldNumber(3, 0, 60, 0.1), "duration_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.plip); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_plipPickupWait", function () {
|
||||
this.appendDummyInput().appendField("☎ PLIP attendre décroché — timeout")
|
||||
.appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.plip); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// Énigmes locales du maître — ces blocs ne produisent pas d'actions : ils
|
||||
// attachent les objets IR `scene` / `puzzle` au step courant (un seul de
|
||||
// chaque par step, voir specs/ZACUS_RUNTIME_3_SPEC.md « Step extras »).
|
||||
def("zacus_scene", function () {
|
||||
this.appendDummyInput().appendField("🖥 Écran maître — titre")
|
||||
.appendField(new Blockly.FieldTextInput("MISSION QR"), "title");
|
||||
this.appendDummyInput().appendField("sous-titre")
|
||||
.appendField(new Blockly.FieldTextInput("scannez dans l'ordre"), "subtitle");
|
||||
this.appendDummyInput().appendField("symbole").appendField(new Blockly.FieldTextInput("RUN"), "symbol")
|
||||
.appendField("effet").appendField(dropdown(ZACUS_SCENE_EFFECTS), "effect");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleQR", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme QR — slot")
|
||||
.appendField(new Blockly.FieldNumber(3, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("codes (dans l'ordre, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("zacus-qr-1, zacus-qr-2"), "codes");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("3"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleSound", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme son — slot")
|
||||
.appendField(new Blockly.FieldNumber(1, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("mélodie (notes MIDI, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("60, 62, 64, 65"), "melody")
|
||||
.appendField("tolérance").appendField(new Blockly.FieldNumber(1, 0, 12, 1), "tolerance");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("1"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
|
||||
// ── Vague 3 : énigmes manquantes (P2/P4/P5/P6/P7) ─────────────────
|
||||
// Même pattern que puzzleQR/puzzleSound : step-extra `puzzle` (pas une
|
||||
// action), slot id 1-8, paramètres propres à l'énigme + fragment 1-4
|
||||
// chiffres. Couleur ZACUS_COLOR.puzzle.
|
||||
def("zacus_puzzleLED", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme circuit LED — slot")
|
||||
.appendField(new Blockly.FieldNumber(2, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("motif attendu (composants/états, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("R1, LED, SW1"), "pattern");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("3"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleRadio", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme radio — slot")
|
||||
.appendField(new Blockly.FieldNumber(4, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("fréquence cible")
|
||||
.appendField(new Blockly.FieldNumber(1337, 0, 100000, 1), "frequency").appendField("Hz")
|
||||
.appendField("± tolérance").appendField(new Blockly.FieldNumber(10, 0, 10000, 1), "tolerance").appendField("Hz");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("4"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleMorse", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme morse — slot")
|
||||
.appendField(new Blockly.FieldNumber(5, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("message")
|
||||
.appendField(new Blockly.FieldTextInput("ZACUS"), "message")
|
||||
.appendField("mode").appendField(dropdown(ZACUS_MORSE_MODES), "mode");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("5"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleNFC", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme symboles NFC — slot")
|
||||
.appendField(new Blockly.FieldNumber(6, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("tags attendus (ordre, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("7, 2, 11, 4, 9, 1, 8, 3, 12, 6, 10, 5"), "tags");
|
||||
this.appendDummyInput().appendField("ordre imposé").appendField(new Blockly.FieldCheckbox("TRUE"), "ordered");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("67"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
def("zacus_puzzleCoffre", function () {
|
||||
this.appendDummyInput().appendField("🧩 Énigme coffre — slot")
|
||||
.appendField(new Blockly.FieldNumber(7, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("longueur du code")
|
||||
.appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres");
|
||||
this.appendDummyInput().appendField("fragment (1-4 chiffres)")
|
||||
.appendField(new Blockly.FieldTextInput("8"), "fragment");
|
||||
this.appendDummyInput().appendField("carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
// Bloc matériel : lecture d'un tag NFC hors énigme (catégorie Matériel).
|
||||
def("zacus_hwNFCRead", function () {
|
||||
this.appendDummyInput().appendField("NFC — attendu").appendField(new Blockly.FieldTextInput("tag_07"), "expected")
|
||||
.appendField("→").appendField(new Blockly.FieldTextInput("nfc_uid"), "var")
|
||||
.appendField("timeout").appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
|
||||
stack(this, ZACUS_COLOR.hardware); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// ── Vague 4 : assemblage du code final / coffre ───────────────────
|
||||
// Actions (pas des step-extras) mappées aux kinds IR code_assembly /
|
||||
// coffre_verify / code_fragment. phases.climax.code_assembly.
|
||||
// Assemble le code final : longueur totale + mapping puzzle→chiffres
|
||||
// (ex « P1_SON:1-2, P2_CIRCUIT:3, P4_RADIO:4 … »).
|
||||
def("zacus_codeAssembly", function () {
|
||||
this.appendDummyInput().appendField("🔐 Assembler le code — longueur")
|
||||
.appendField(new Blockly.FieldNumber(8, 1, 16, 1), "length").appendField("chiffres");
|
||||
this.appendDummyInput().appendField("fragments (puzzle:positions, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("P1_SON:1-2, P2_CIRCUIT:3, P4_RADIO:4, P5_MORSE:5, P6_SYMBOLES:6-7, P3_QR:8"), "fragments");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
// Associe explicitement un fragment de puzzle à une (ou des) position(s).
|
||||
def("zacus_codeFragment", function () {
|
||||
this.appendDummyInput().appendField("↳ Fragment puzzle")
|
||||
.appendField(new Blockly.FieldTextInput("P1_SON"), "puzzle")
|
||||
.appendField("→ positions").appendField(new Blockly.FieldTextInput("1-2"), "positions");
|
||||
stack(this, ZACUS_COLOR.puzzle); this.setInputsInline(true);
|
||||
});
|
||||
// Vérifie le code assemblé : déclenche l'ouverture / la fin.
|
||||
def("zacus_coffreVerify", function () {
|
||||
this.appendDummyInput().appendField("✅ Vérifier coffre — slot")
|
||||
.appendField(new Blockly.FieldNumber(7, 1, 8, 1), "id");
|
||||
this.appendDummyInput().appendField("au succès aller à")
|
||||
.appendField(new Blockly.FieldTextInput("outro"), "on_success");
|
||||
stack(this, ZACUS_COLOR.puzzle);
|
||||
});
|
||||
|
||||
// ── Phase D2 : binding carte ──────────────────────────────────────
|
||||
// Bloc contexte c-block « ▸ sur la carte X { … } » : il ne produit aucune
|
||||
// action propre, il TAGUE chaque action de son slot `body` avec le `board`
|
||||
// choisi (cf. blocks_to_runtime3.py). previous+next connectés. Réel : le
|
||||
// routage gateway s'appuie dessus.
|
||||
def("zacus_hwOnBoard", function () {
|
||||
this.appendDummyInput().appendField("▸ sur la carte").appendField(dropdown(ZACUS_BOARDS), "board");
|
||||
this.appendStatementInput("body").setCheck(null).appendField("faire");
|
||||
stack(this, ZACUS_COLOR.hardware);
|
||||
});
|
||||
|
||||
// ── Phase D4 : transitions réelles ────────────────────────────────
|
||||
// Blocs « Quand … → aller à [target] » : chacun émet UNE transition avec le
|
||||
// vrai event_type firmware-backed (button/serial/audio_done/unlock/espnow).
|
||||
// event_type ∈ runtime3_common.EVENT_TYPES (pas d'extension de schéma, juste
|
||||
// de l'émission). target = id d'étape cible (même pattern que sceneGoto).
|
||||
// Voie RÉELLE : consommés côté gateway/web (le firmware n'a pas de moteur de
|
||||
// transitions, il est piloté pas-à-pas). Compile dans blocks_to_runtime3.py.
|
||||
def("zacus_onButton", function () {
|
||||
this.appendDummyInput().appendField("Quand bouton")
|
||||
.appendField(new Blockly.FieldTextInput("NEXT"), "button")
|
||||
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_onAudioDone", function () {
|
||||
this.appendDummyInput().appendField("Quand audio terminé → aller à")
|
||||
.appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_onUnlock", function () {
|
||||
this.appendDummyInput().appendField("Quand déverrouillage")
|
||||
.appendField(new Blockly.FieldTextInput("door"), "source")
|
||||
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_onSerial", function () {
|
||||
this.appendDummyInput().appendField("Quand série reçoit")
|
||||
.appendField(new Blockly.FieldTextInput("OK"), "token")
|
||||
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_onEspnow", function () {
|
||||
this.appendDummyInput().appendField("Quand ESP-NOW reçoit")
|
||||
.appendField(new Blockly.FieldTextInput("ready"), "command")
|
||||
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_onPuzzleSolved", function () {
|
||||
this.appendDummyInput().appendField("Quand énigme résolue")
|
||||
.appendField(new Blockly.FieldTextInput("1"), "puzzle")
|
||||
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_onCodeAssembled", function () {
|
||||
this.appendDummyInput().appendField("Quand code assemblé")
|
||||
.appendField(new Blockly.FieldTextInput("4242"), "code")
|
||||
.appendField("→ aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
|
||||
stack(this, ZACUS_COLOR.scene); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// Logique
|
||||
def("zacus_logicIf", function () {
|
||||
// condition : reporter branché (appendValueInput "condition") OU champ
|
||||
// texte de repli (rétrocompat strings opaques).
|
||||
this.appendValueInput("condition").setCheck(null).appendField("si");
|
||||
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("score > 0"), "condition");
|
||||
this.appendStatementInput("body").setCheck(null).appendField("alors");
|
||||
this.appendStatementInput("else").setCheck(null).appendField("sinon");
|
||||
stack(this, ZACUS_COLOR.logic);
|
||||
});
|
||||
def("zacus_logicTimer", function () {
|
||||
this.appendDummyInput().appendField("Attendre")
|
||||
.appendField(new Blockly.FieldNumber(5, 0, 600, 0.1), "seconds").appendField("s");
|
||||
stack(this, ZACUS_COLOR.logic); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_logicScore", function () {
|
||||
this.appendDummyInput().appendField("Score +")
|
||||
.appendField(new Blockly.FieldNumber(1, -100, 100, 1), "delta");
|
||||
stack(this, ZACUS_COLOR.logic); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_logicSetVar", function () {
|
||||
this.appendDummyInput().appendField("Variable").appendField(new Blockly.FieldTextInput("key"), "name")
|
||||
.appendField(":=").appendField(new Blockly.FieldTextInput("42"), "value");
|
||||
stack(this, ZACUS_COLOR.logic); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// ── Vague 1 : Opérateurs (reporters) ──────────────────────────────
|
||||
// Tous setOutput(true) : pas de previous/next statement, ils se branchent
|
||||
// dans un appendValueInput (condition de logicIf/loopWhile/sceneBranch ou
|
||||
// un opérande d'un autre opérateur). Sérialisés en string d'expression.
|
||||
def("zacus_opCompare", function () {
|
||||
this.appendValueInput("A").setCheck(null);
|
||||
this.appendDummyInput().appendField(dropdown(ZACUS_COMPARE_OPS), "op");
|
||||
this.appendValueInput("B").setCheck(null);
|
||||
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
|
||||
});
|
||||
def("zacus_opBool", function () {
|
||||
this.appendValueInput("A").setCheck(null);
|
||||
this.appendDummyInput().appendField(dropdown(ZACUS_BOOL_OPS), "op");
|
||||
this.appendValueInput("B").setCheck(null);
|
||||
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
|
||||
});
|
||||
def("zacus_opNot", function () {
|
||||
this.appendValueInput("A").setCheck(null).appendField("non");
|
||||
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
|
||||
});
|
||||
def("zacus_opArith", function () {
|
||||
this.appendValueInput("A").setCheck(null);
|
||||
this.appendDummyInput().appendField(dropdown(ZACUS_ARITH_OPS), "op");
|
||||
this.appendValueInput("B").setCheck(null);
|
||||
this.setInputsInline(true); this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator);
|
||||
});
|
||||
def("zacus_valVar", function () {
|
||||
this.appendDummyInput().appendField("variable").appendField(new Blockly.FieldTextInput("pct"), "name");
|
||||
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_valScore", function () {
|
||||
this.appendDummyInput().appendField("score courant");
|
||||
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_valNumber", function () {
|
||||
this.appendDummyInput().appendField(new Blockly.FieldNumber(0), "num");
|
||||
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
|
||||
});
|
||||
def("zacus_valText", function () {
|
||||
this.appendDummyInput().appendField("“").appendField(new Blockly.FieldTextInput("texte"), "text").appendField("”");
|
||||
this.setOutput(true, null); this.setColour(ZACUS_COLOR.operator); this.setInputsInline(true);
|
||||
});
|
||||
|
||||
// ── Vague 1 : Contrôle (statements c-block) ───────────────────────
|
||||
def("zacus_loopRepeat", function () {
|
||||
this.appendDummyInput().appendField("répéter")
|
||||
.appendField(new Blockly.FieldNumber(10, 0, 100000, 1), "times").appendField("fois");
|
||||
this.appendStatementInput("body").setCheck(null).appendField("faire");
|
||||
stack(this, ZACUS_COLOR.control);
|
||||
});
|
||||
def("zacus_loopWhile", function () {
|
||||
this.appendValueInput("condition").setCheck(null).appendField("tant que");
|
||||
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("score < 5"), "condition_text");
|
||||
this.appendStatementInput("body").setCheck(null).appendField("faire");
|
||||
stack(this, ZACUS_COLOR.control);
|
||||
});
|
||||
|
||||
// ── Vague 2 : Maître du jeu (game-master adaptatif) ───────────────
|
||||
// Définit une phase de jeu (intro/profiling/adaptive/climax/outro) avec
|
||||
// une durée et un déclencheur. phases.*.{id,duration_minutes,trigger}.
|
||||
def("zacus_phase", function () {
|
||||
this.appendDummyInput().appendField("🎬 Phase").appendField(new Blockly.FieldTextInput("adaptive"), "id");
|
||||
this.appendDummyInput().appendField("durée")
|
||||
.appendField(new Blockly.FieldNumber(20, 0, 240, 1), "duration_minutes").appendField("min");
|
||||
this.appendDummyInput().appendField("déclencheur").appendField(dropdown(ZACUS_PHASE_TRIGGERS), "trigger");
|
||||
stack(this, ZACUS_COLOR.gm);
|
||||
});
|
||||
// Règle condition→action du game-master (npc_mood_rules.*). La condition
|
||||
// accepte un reporter branché OU un texte (comme logicIf).
|
||||
def("zacus_gmRule", function () {
|
||||
this.appendValueInput("condition").setCheck(null).appendField("⚖ Règle si");
|
||||
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("pct > 130"), "condition");
|
||||
this.appendDummyInput().appendField("→ action").appendField(dropdown(ZACUS_GM_ACTIONS), "action");
|
||||
stack(this, ZACUS_COLOR.gm);
|
||||
});
|
||||
// Humeur du NPC (npc_mood_rules.*.mood).
|
||||
def("zacus_npcSetMood", function () {
|
||||
this.appendDummyInput().appendField("😐 Humeur NPC").appendField(dropdown(ZACUS_NPC_MOODS), "mood");
|
||||
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
|
||||
});
|
||||
// Donner un indice de niveau N avec sa clé (hint_keys.level_N + give level).
|
||||
def("zacus_giveHint", function () {
|
||||
this.appendDummyInput().appendField("💡 Indice niveau").appendField(dropdown([["1","1"],["2","2"],["3","3"]]), "level")
|
||||
.appendField("clé").appendField(new Blockly.FieldTextInput("hints.P5_MORSE.level_1"), "hint_key");
|
||||
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
|
||||
});
|
||||
// Indice proactif à intervalle régulier (hint_escalation.proactive_hint_interval_seconds).
|
||||
def("zacus_proactiveHint", function () {
|
||||
this.appendDummyInput().appendField("🔔 Indice proactif toutes les")
|
||||
.appendField(new Blockly.FieldNumber(90, 1, 3600, 1), "interval_seconds").appendField("s")
|
||||
.appendField("clé").appendField(new Blockly.FieldTextInput("hints.P5_MORSE.level_1"), "hint_key");
|
||||
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
|
||||
});
|
||||
// Définit le parcours (liste de puzzles) pour un profil/classe
|
||||
// (group_classification.*.parcours).
|
||||
def("zacus_setParcours", function () {
|
||||
this.appendDummyInput().appendField("🧭 Parcours profil").appendField(new Blockly.FieldTextInput("TECH"), "profile");
|
||||
this.appendDummyInput().appendField("puzzles (dans l'ordre, virgules)")
|
||||
.appendField(new Blockly.FieldTextInput("P2_CIRCUIT, P4_RADIO, P5_MORSE"), "puzzles");
|
||||
stack(this, ZACUS_COLOR.gm);
|
||||
});
|
||||
// Bonus conditionnel (scoring.bonus_*). condition = reporter branché OU texte.
|
||||
def("zacus_scoreBonus", function () {
|
||||
this.appendValueInput("condition").setCheck(null).appendField("🏆 Bonus si");
|
||||
this.appendDummyInput().appendField("ou texte").appendField(new Blockly.FieldTextInput("hints_used == 0"), "condition");
|
||||
this.appendDummyInput().appendField("→ +").appendField(new Blockly.FieldNumber(200, 0, 10000, 1), "points").appendField("pts");
|
||||
stack(this, ZACUS_COLOR.gm);
|
||||
});
|
||||
// Pénalité de score (scoring.time_penalty_per_minute / hint_penalty).
|
||||
def("zacus_scorePenalty", function () {
|
||||
this.appendDummyInput().appendField("⛔ Pénalité").appendField(dropdown(ZACUS_PENALTY_KINDS), "penalty_kind")
|
||||
.appendField("−").appendField(new Blockly.FieldNumber(10, 0, 10000, 1), "points").appendField("pts");
|
||||
stack(this, ZACUS_COLOR.gm); this.setInputsInline(true);
|
||||
});
|
||||
}
|
||||
|
||||
export const ZACUS_BLOCK_TYPES = [
|
||||
"zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
|
||||
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
|
||||
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
|
||||
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
|
||||
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
|
||||
"zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone","zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait","zacus_hwNFCRead",
|
||||
"zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
|
||||
"zacus_boxIMUShake","zacus_boxIRSend",
|
||||
"zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
|
||||
"zacus_plipRing","zacus_plipPickupWait",
|
||||
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
|
||||
"zacus_puzzleLED","zacus_puzzleRadio","zacus_puzzleMorse","zacus_puzzleNFC","zacus_puzzleCoffre",
|
||||
"zacus_codeAssembly","zacus_codeFragment","zacus_coffreVerify",
|
||||
"zacus_hwOnBoard",
|
||||
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
|
||||
"zacus_opCompare","zacus_opBool","zacus_opNot","zacus_opArith",
|
||||
"zacus_valVar","zacus_valScore","zacus_valNumber","zacus_valText",
|
||||
"zacus_loopRepeat","zacus_loopWhile",
|
||||
"zacus_phase","zacus_gmRule","zacus_npcSetMood","zacus_giveHint","zacus_proactiveHint","zacus_setParcours","zacus_scoreBonus","zacus_scorePenalty",
|
||||
];
|
||||
|
||||
export const ZACUS_FIELDS_BY_KIND = {
|
||||
zacus_sceneStart: ["id"],
|
||||
zacus_sceneEnd: [],
|
||||
zacus_sceneGoto: ["target"],
|
||||
zacus_sceneBranch: ["condition","ifTrue","ifFalse"],
|
||||
zacus_npcSay: ["text","voice","emotion","rate"],
|
||||
zacus_npcWaitResponse: ["timeout_s"],
|
||||
zacus_npcIntentMatch: ["intent","then"],
|
||||
// Phase D4 — transitions réelles : « Quand … → aller à [target] ».
|
||||
zacus_onButton: ["button","target"],
|
||||
zacus_onAudioDone: ["target"],
|
||||
zacus_onUnlock: ["source","target"],
|
||||
zacus_onSerial: ["token","target"],
|
||||
zacus_onEspnow: ["command","target"],
|
||||
zacus_onPuzzleSolved: ["puzzle","target"],
|
||||
zacus_onCodeAssembled: ["code","target"],
|
||||
zacus_hwSoundPlay: ["asset"],
|
||||
zacus_hwAudioStop: [],
|
||||
zacus_hwAudioVolume: ["level"],
|
||||
// Phase D3 — média réel SD : chemin SD + boucle. board hérité de hwOnBoard.
|
||||
zacus_mediaPlay: ["path","loop"],
|
||||
zacus_hwLCDText: ["line","text"],
|
||||
zacus_hwLCDClear: [],
|
||||
zacus_hwLCDImage: ["asset"],
|
||||
zacus_hwLCDTouchWait: ["zone","timeout_s"],
|
||||
zacus_hwServo: ["channel","angle"],
|
||||
zacus_hwReadQR: ["expected"],
|
||||
zacus_hwLEDPattern: ["pattern"],
|
||||
zacus_hwBuzzerTone: ["freq","ms"],
|
||||
zacus_hwRelay: ["channel","state"],
|
||||
zacus_hwSensorRead: ["pin","var"],
|
||||
zacus_hwButtonWait: ["button","timeout_s"],
|
||||
zacus_hwNFCRead: ["expected","var","timeout_s"],
|
||||
zacus_espnowRegisterPeer: ["alias","mac"],
|
||||
zacus_espnowSend: ["peer","command"],
|
||||
zacus_espnowBroadcast: ["command"],
|
||||
zacus_espnowWait: ["command","timeout_s"],
|
||||
zacus_boxIMUShake: ["threshold","timeout_s"],
|
||||
zacus_boxIRSend: ["protocol","code"],
|
||||
zacus_m5Beep: ["freq","ms"],
|
||||
zacus_m5LCDText: ["text","color","size"],
|
||||
zacus_m5ButtonAB: ["button","timeout_s"],
|
||||
zacus_m5RGBLed: ["color"],
|
||||
zacus_m5IMUShake: ["threshold","timeout_s"],
|
||||
zacus_plipRing: ["duration_s"],
|
||||
zacus_plipPickupWait: ["timeout_s"],
|
||||
zacus_scene: ["title","subtitle","symbol","effect"],
|
||||
zacus_puzzleQR: ["id","codes","fragment","board"],
|
||||
zacus_puzzleSound: ["id","melody","tolerance","fragment","board"],
|
||||
// Phase D2 — binding carte : bloc contexte (slot `body`, champ `board`).
|
||||
zacus_hwOnBoard: ["board"],
|
||||
// Vague 3 — énigmes manquantes (step-extra `puzzle`).
|
||||
zacus_puzzleLED: ["id","pattern","fragment","board"],
|
||||
zacus_puzzleRadio: ["id","frequency","tolerance","fragment","board"],
|
||||
zacus_puzzleMorse: ["id","message","mode","fragment","board"],
|
||||
zacus_puzzleNFC: ["id","tags","ordered","fragment","board"],
|
||||
zacus_puzzleCoffre: ["id","length","fragment","board"],
|
||||
// Vague 4 — assemblage du code final (actions).
|
||||
zacus_codeAssembly: ["length","fragments"],
|
||||
zacus_codeFragment: ["puzzle","positions"],
|
||||
zacus_coffreVerify: ["id","on_success"],
|
||||
zacus_logicIf: ["condition"],
|
||||
zacus_logicTimer: ["seconds"],
|
||||
zacus_logicScore: ["delta"],
|
||||
zacus_logicSetVar: ["name","value"],
|
||||
// Vague 1 — opérateurs (reporters). Les opérandes viennent des value
|
||||
// inputs (sérialisés via `inputs`), seuls le dropdown/littéral sont des
|
||||
// champs.
|
||||
zacus_opCompare: ["op"],
|
||||
zacus_opBool: ["op"],
|
||||
zacus_opNot: [],
|
||||
zacus_opArith: ["op"],
|
||||
zacus_valVar: ["name"],
|
||||
zacus_valScore: [],
|
||||
zacus_valNumber: ["num"],
|
||||
zacus_valText: ["text"],
|
||||
// Vague 1 — contrôle.
|
||||
zacus_loopRepeat: ["times"],
|
||||
zacus_loopWhile: ["condition_text"],
|
||||
// Vague 2 — Maître du jeu. Les conditions des règles/bonus sont des value
|
||||
// inputs (sérialisées via `inputs`) avec repli texte `condition`.
|
||||
zacus_phase: ["id","duration_minutes","trigger"],
|
||||
zacus_gmRule: ["condition","action"],
|
||||
zacus_npcSetMood: ["mood"],
|
||||
zacus_giveHint: ["level","hint_key"],
|
||||
zacus_proactiveHint: ["interval_seconds","hint_key"],
|
||||
zacus_setParcours: ["profile","puzzles"],
|
||||
zacus_scoreBonus: ["condition","points"],
|
||||
zacus_scorePenalty: ["penalty_kind","points"],
|
||||
};
|
||||
|
||||
// ── DSL honnête : voies « réel » vs « hardware futur » (phase D1) ───────
|
||||
// Un bloc est RÉEL s'il est effectivement exécuté — soit on-device (firmware
|
||||
// Freenove master : scene/QR/sound, média, relais ESP-NOW), soit côté gateway
|
||||
// (logique de transitions/score/variables, couche game-master, opérateurs).
|
||||
// Un bloc HARDWARE FUTUR est validé et compilé en IR comme avant, mais AUCUN
|
||||
// executor ne le joue (servo, relais, buzzer, LCD tactile, NFC, énigmes
|
||||
// LED/Radio/Morse/NFC/Coffre, assemblage code/coffre, cartes M5/BOX-3/PLIP).
|
||||
// La compilation (blocks_to_runtime3.py) émet un *warning* non bloquant pour
|
||||
// ces blocs. Garde cette liste en miroir de FUTURE_HW_KINDS côté Python (kinds
|
||||
// sans le préfixe `zacus_`).
|
||||
export const ZACUS_REAL_KINDS = new Set([
|
||||
"zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
|
||||
"zacus_scene","zacus_puzzleQR","zacus_puzzleSound",
|
||||
"zacus_hwOnBoard",
|
||||
"zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
|
||||
"zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled",
|
||||
"zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume","zacus_mediaPlay",
|
||||
"zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
|
||||
"zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
|
||||
"zacus_opCompare","zacus_opBool","zacus_opNot","zacus_opArith",
|
||||
"zacus_valVar","zacus_valScore","zacus_valNumber","zacus_valText",
|
||||
"zacus_loopRepeat","zacus_loopWhile",
|
||||
"zacus_phase","zacus_gmRule","zacus_npcSetMood","zacus_giveHint","zacus_proactiveHint",
|
||||
"zacus_setParcours","zacus_scoreBonus","zacus_scorePenalty",
|
||||
]);
|
||||
|
||||
export const ZACUS_FUTURE_HW_KINDS = new Set([
|
||||
"zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
|
||||
"zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone",
|
||||
"zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait","zacus_hwNFCRead",
|
||||
"zacus_puzzleLED","zacus_puzzleRadio","zacus_puzzleMorse","zacus_puzzleNFC","zacus_puzzleCoffre",
|
||||
"zacus_codeAssembly","zacus_codeFragment","zacus_coffreVerify",
|
||||
"zacus_boxIMUShake","zacus_boxIRSend",
|
||||
"zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
|
||||
"zacus_plipRing","zacus_plipPickupWait",
|
||||
]);
|
||||
|
||||
// Toolbox réorganisée en deux voies : catégories RÉELLES en haut, catégories
|
||||
// « 🔬 … (futur) » regroupées en bas. Chaque type apparaît exactement une fois ;
|
||||
// l'union des deux voies == ZACUS_BLOCK_TYPES.
|
||||
export const ZACUS_TOOLBOX_CATEGORIES = [
|
||||
// ── Voie réelle (exécutée on-device ou côté gateway) ──────────────────
|
||||
{ name: "Cartes", colour: ZACUS_COLOR.hardware, types: ["zacus_hwOnBoard"] },
|
||||
{ name: "Scènes", colour: ZACUS_COLOR.scene, types: ["zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch"] },
|
||||
{ name: "Transitions", colour: ZACUS_COLOR.scene, types: ["zacus_onButton","zacus_onAudioDone","zacus_onUnlock","zacus_onSerial","zacus_onEspnow","zacus_onPuzzleSolved","zacus_onCodeAssembled"] },
|
||||
{ name: "NPC voix", colour: ZACUS_COLOR.npc, types: ["zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch"] },
|
||||
{ name: "Audio", colour: ZACUS_COLOR.audio, types: ["zacus_mediaPlay","zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume"] },
|
||||
{ name: "ESP-NOW", colour: ZACUS_COLOR.espnow, types: ["zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait"] },
|
||||
{ name: "Énigmes", colour: ZACUS_COLOR.puzzle, types: ["zacus_scene","zacus_puzzleQR","zacus_puzzleSound"] },
|
||||
{ name: "Logique", colour: ZACUS_COLOR.logic, types: ["zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar"] },
|
||||
{ name: "Opérateurs", colour: ZACUS_COLOR.operator, types: ["zacus_opCompare","zacus_opBool","zacus_opNot","zacus_opArith","zacus_valVar","zacus_valScore","zacus_valNumber","zacus_valText"] },
|
||||
{ name: "Contrôle", colour: ZACUS_COLOR.control, types: ["zacus_loopRepeat","zacus_loopWhile"] },
|
||||
{ name: "Maître du jeu", colour: ZACUS_COLOR.gm, types: ["zacus_phase","zacus_gmRule","zacus_npcSetMood","zacus_giveHint","zacus_proactiveHint","zacus_setParcours","zacus_scoreBonus","zacus_scorePenalty"] },
|
||||
// ── Voie hardware futur (validée, non exécutée on-device) ─────────────
|
||||
{ name: "🔬 Énigmes (futur)", colour: ZACUS_COLOR.puzzle, types: ["zacus_puzzleLED","zacus_puzzleRadio","zacus_puzzleMorse","zacus_puzzleNFC","zacus_puzzleCoffre"] },
|
||||
{ name: "🔬 Coffre (futur)", colour: ZACUS_COLOR.puzzle, types: ["zacus_codeAssembly","zacus_codeFragment","zacus_coffreVerify"] },
|
||||
{ name: "🔬 LCD (futur)", colour: ZACUS_COLOR.lcd, types: ["zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait"] },
|
||||
{ name: "🔬 Matériel (futur)", colour: ZACUS_COLOR.hardware, types: ["zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone","zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait","zacus_hwNFCRead"] },
|
||||
{ name: "🔬 BOX-3 (futur)", colour: ZACUS_COLOR.box3, types: ["zacus_boxIMUShake","zacus_boxIRSend"] },
|
||||
{ name: "🔬 M5 (futur)", colour: ZACUS_COLOR.m5, types: ["zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake"] },
|
||||
{ name: "🔬 PLIP (futur)", colour: ZACUS_COLOR.plip, types: ["zacus_plipRing","zacus_plipPickupWait"] },
|
||||
];
|
||||
@@ -9,6 +9,10 @@
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./blockly": {
|
||||
"types": "./blockly/zacus_blocks.d.mts",
|
||||
"import": "./blockly/zacus_blocks.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
serializeBlocksDoc,
|
||||
parseBlocksDocYaml,
|
||||
type BlocksNode,
|
||||
} from './blocks-doc.js';
|
||||
// @ts-expect-error — plain .mjs catalog, typed via zacus_blocks.d.mts at the package export.
|
||||
import { ZACUS_FIELDS_BY_KIND, ZACUS_BLOCK_TYPES, ZACUS_TOOLBOX_CATEGORIES } from '../blockly/zacus_blocks.mjs';
|
||||
|
||||
// Phase D3 — média réel SD. The Atelier exporter (yaml-export.ts) snapshots
|
||||
// each block's fields generically from ZACUS_FIELDS_BY_KIND. This test pins
|
||||
// the catalog contract for `mediaPlay` and proves the generic serializer round
|
||||
// -trips its `path` + `loop` params with no per-block special-casing.
|
||||
|
||||
describe('mediaPlay catalog contract (D3.4)', () => {
|
||||
it('declares mediaPlay with path + loop fields', () => {
|
||||
expect(ZACUS_FIELDS_BY_KIND.zacus_mediaPlay).toEqual(['path', 'loop']);
|
||||
});
|
||||
|
||||
it('registers mediaPlay as a known block type', () => {
|
||||
expect(ZACUS_BLOCK_TYPES).toContain('zacus_mediaPlay');
|
||||
});
|
||||
|
||||
it('places mediaPlay in the real Audio toolbox category', () => {
|
||||
const audio = ZACUS_TOOLBOX_CATEGORIES.find(
|
||||
(c: { name: string }) => c.name === 'Audio',
|
||||
);
|
||||
expect(audio?.types).toContain('zacus_mediaPlay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mediaPlay serialization round-trip (D3.4)', () => {
|
||||
it('serializes path + loop params and parses them back unchanged', () => {
|
||||
// Mirror what workspaceToBlocksNodes produces for a mediaPlay block: a
|
||||
// node carrying the catalog fields as params.
|
||||
const node: BlocksNode = {
|
||||
id: 'm1',
|
||||
kind: 'mediaPlay',
|
||||
position: [0, 0],
|
||||
params: { path: 'zacus/master/intro.wav', loop: 'TRUE' },
|
||||
};
|
||||
const yaml = serializeBlocksDoc([node]);
|
||||
const parsed = parseBlocksDocYaml(yaml);
|
||||
expect(parsed.nodes).toHaveLength(1);
|
||||
expect(parsed.nodes[0].kind).toBe('mediaPlay');
|
||||
expect(parsed.nodes[0].params).toEqual({
|
||||
path: 'zacus/master/intro.wav',
|
||||
loop: 'TRUE',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* blocks-doc — the `blocks_studio_version: 2` exchange format shared by the
|
||||
* Atelier webapp, the zacus-hub native Studio and the gateway compiler
|
||||
* (tools/zacus-gateway/blocks_to_runtime3.py).
|
||||
*
|
||||
* A document is a flat list of nodes; chains are rebuilt from `next`
|
||||
* pointers, c-block branches (logicIf "alors"/"sinon", loop bodies) from
|
||||
* `slots`, and reporter operands (value inputs) from `inputs`.
|
||||
* Kinds carry no `zacus_` prefix (that prefix belongs to the Blockly block
|
||||
* type names only).
|
||||
*
|
||||
* Vague 1 reporter kinds (`opCompare`, `opBool`, `opNot`, `opArith`,
|
||||
* `valVar`, `valScore`, `valNumber`, `valText`) are value blocks: they have
|
||||
* no `next`/statement connection and are referenced through another node's
|
||||
* `inputs`. `serializeReporter()` resolves them to an expression string in
|
||||
* the loose condition grammar (`score > 5`, `(a) and (b)`), so logicIf /
|
||||
* loopWhile / sceneBranch can take either a branched reporter or an opaque
|
||||
* typed string. Control kinds: `loopRepeat` (times + body slot), `loopWhile`
|
||||
* (condition value input or `condition_text` + body slot).
|
||||
*
|
||||
* Vague 2 « Maître du jeu » (game-master adaptatif) kinds: `phase`, `gmRule`,
|
||||
* `npcSetMood`, `giveHint`, `proactiveHint`, `setParcours`, `scoreBonus`,
|
||||
* `scorePenalty`. `gmRule` / `scoreBonus` take a branched condition reporter
|
||||
* (value input `condition`) OR the `condition` text param, resolved the same
|
||||
* way as logicIf via `serializeReporter()`. They are statement blocks (no
|
||||
* output), mapped to game-master IR actions by the gateway.
|
||||
*
|
||||
* Vague 3 « énigmes manquantes » kinds (step-level `puzzle` objects, like
|
||||
* `puzzleQR`/`puzzleSound`, never actions): `puzzleLED` (type led),
|
||||
* `puzzleRadio` (type radio), `puzzleMorse` (type morse), `puzzleNFC`
|
||||
* (type nfc), `puzzleCoffre` (type keypad). Plus `hwNFCRead` (action
|
||||
* `nfc_read`). Vague 4 « assemblage du code / coffre » kinds (actions):
|
||||
* `codeAssembly` (IR code_assembly), `codeFragment` (IR code_fragment),
|
||||
* `coffreVerify` (IR coffre_verify). See blocks_to_runtime3.py for the IR
|
||||
* mapping and runtime3_common.py for the validation contract.
|
||||
*/
|
||||
import { load } from 'js-yaml';
|
||||
|
||||
export const BLOCKS_STUDIO_VERSION = 2;
|
||||
|
||||
export interface BlocksNode {
|
||||
id: string;
|
||||
kind: string;
|
||||
position: [number, number];
|
||||
next?: string;
|
||||
params?: Record<string, string | number>;
|
||||
slots?: Record<string, string>;
|
||||
/** Reporter blocks plugged into value inputs, keyed by input name
|
||||
* (e.g. {A: "id1", B: "id2"} for opCompare, {condition: "id"} for
|
||||
* logicIf). Resolved to an expression string by the gateway. */
|
||||
inputs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BlocksDocument {
|
||||
version: number;
|
||||
nodes: BlocksNode[];
|
||||
}
|
||||
|
||||
/** Serialize nodes to the v2 YAML — same shape BlocksYAML.swift emits.
|
||||
* Strings are JSON-quoted (valid YAML scalars, multiline-safe). */
|
||||
export function serializeBlocksDoc(nodes: readonly BlocksNode[]): string {
|
||||
const lines: string[] = [`blocks_studio_version: ${BLOCKS_STUDIO_VERSION}`, 'nodes:'];
|
||||
if (nodes.length === 0) {
|
||||
lines.push(' []');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
for (const n of nodes) {
|
||||
lines.push(` - id: ${JSON.stringify(n.id)}`);
|
||||
lines.push(` kind: ${n.kind}`);
|
||||
lines.push(` position: [${Math.round(n.position[0])}, ${Math.round(n.position[1])}]`);
|
||||
if (n.next) lines.push(` next: ${JSON.stringify(n.next)}`);
|
||||
const params = n.params ?? {};
|
||||
const pkeys = Object.keys(params);
|
||||
if (pkeys.length > 0) {
|
||||
lines.push(' params:');
|
||||
for (const k of pkeys) {
|
||||
const v = params[k];
|
||||
lines.push(` ${k}: ${typeof v === 'number' ? v : JSON.stringify(v ?? '')}`);
|
||||
}
|
||||
}
|
||||
const slots = n.slots ?? {};
|
||||
const skeys = Object.keys(slots);
|
||||
if (skeys.length > 0) {
|
||||
lines.push(' slots:');
|
||||
for (const k of skeys) lines.push(` ${k}: ${JSON.stringify(slots[k])}`);
|
||||
}
|
||||
const inputs = n.inputs ?? {};
|
||||
const ikeys = Object.keys(inputs);
|
||||
if (ikeys.length > 0) {
|
||||
lines.push(' inputs:');
|
||||
for (const k of ikeys) lines.push(` ${k}: ${JSON.stringify(inputs[k])}`);
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a reporter node (and its nested value-input children) to an
|
||||
* expression string in the loose grammar the scenario conditions already use
|
||||
* (`score > 5`, `pct >= 60 and lives > 0`). Mirrors the Python implementation
|
||||
* in tools/zacus-gateway/blocks_to_runtime3.py (`_reporter_to_expr`).
|
||||
*
|
||||
* Returns "" when the node/ref is missing so callers can fall back to a typed
|
||||
* text field (backward-compat with opaque condition strings).
|
||||
*/
|
||||
export function serializeReporter(
|
||||
nodeId: string | undefined,
|
||||
byId: Readonly<Record<string, BlocksNode>>,
|
||||
): string {
|
||||
if (!nodeId) return '';
|
||||
const node = byId[nodeId];
|
||||
if (!node) return '';
|
||||
const p = node.params ?? {};
|
||||
const inp = node.inputs ?? {};
|
||||
const operand = (name: string): string => serializeReporter(inp[name], byId);
|
||||
|
||||
switch (node.kind) {
|
||||
case 'valScore':
|
||||
return 'score';
|
||||
case 'valVar':
|
||||
return String(p['name'] ?? '').trim();
|
||||
case 'valNumber': {
|
||||
const n = p['num'];
|
||||
return n === undefined || n === null ? '0' : String(n);
|
||||
}
|
||||
case 'valText':
|
||||
return JSON.stringify(String(p['text'] ?? ''));
|
||||
case 'opCompare': {
|
||||
const a = operand('A'), b = operand('B');
|
||||
const op = String(p['op'] ?? '==');
|
||||
return `${a} ${op} ${b}`;
|
||||
}
|
||||
case 'opArith': {
|
||||
const a = operand('A'), b = operand('B');
|
||||
const op = String(p['op'] ?? '+');
|
||||
return `(${a} ${op} ${b})`;
|
||||
}
|
||||
case 'opBool': {
|
||||
const a = operand('A'), b = operand('B');
|
||||
const op = String(p['op'] ?? 'and');
|
||||
return `(${a}) ${op} (${b})`;
|
||||
}
|
||||
case 'opNot':
|
||||
return `not (${operand('A')})`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a v2 YAML document. Throws on anything that is not a
|
||||
* blocks_studio_version: 2 mapping with a well-formed nodes list. */
|
||||
export function parseBlocksDocYaml(text: string): BlocksDocument {
|
||||
const doc = load(text) as Record<string, unknown> | null;
|
||||
if (!doc || typeof doc !== 'object') {
|
||||
throw new Error('YAML : document vide ou racine non-mapping');
|
||||
}
|
||||
const version = doc['blocks_studio_version'];
|
||||
if (version !== BLOCKS_STUDIO_VERSION) {
|
||||
throw new Error(
|
||||
`blocks_studio_version ${String(version)} non supporté (attendu: ${BLOCKS_STUDIO_VERSION})`,
|
||||
);
|
||||
}
|
||||
const rawNodes = doc['nodes'];
|
||||
const nodes: BlocksNode[] = [];
|
||||
if (Array.isArray(rawNodes)) {
|
||||
for (const raw of rawNodes) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r['id'] !== 'string' || typeof r['kind'] !== 'string') continue;
|
||||
const pos = Array.isArray(r['position']) ? (r['position'] as unknown[]) : [0, 0];
|
||||
const node: BlocksNode = {
|
||||
id: r['id'],
|
||||
kind: r['kind'],
|
||||
position: [Number(pos[0]) || 0, Number(pos[1]) || 0],
|
||||
};
|
||||
if (typeof r['next'] === 'string') node.next = r['next'];
|
||||
if (r['params'] && typeof r['params'] === 'object') {
|
||||
const params: Record<string, string | number> = {};
|
||||
for (const [k, v] of Object.entries(r['params'] as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' || typeof v === 'number') params[k] = v;
|
||||
else if (v != null) params[k] = String(v);
|
||||
}
|
||||
node.params = params;
|
||||
}
|
||||
if (r['slots'] && typeof r['slots'] === 'object') {
|
||||
const slots: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(r['slots'] as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') slots[k] = v;
|
||||
}
|
||||
node.slots = slots;
|
||||
}
|
||||
if (r['inputs'] && typeof r['inputs'] === 'object') {
|
||||
const inputs: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(r['inputs'] as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') inputs[k] = v;
|
||||
}
|
||||
node.inputs = inputs;
|
||||
}
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
return { version: BLOCKS_STUDIO_VERSION, nodes };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './types.js';
|
||||
export * from './constants.js';
|
||||
export * from './yaml-parser.js';
|
||||
export * from './blocks-doc.js';
|
||||
|
||||
Generated
+182
-515
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
# Livre dont vous êtes le héros — L'Énigme du Pharaon (aventure)
|
||||
title: "L'Énigme du Pharaon"
|
||||
voice: Thomas
|
||||
start: entree
|
||||
|
||||
passages:
|
||||
entree:
|
||||
screen: "L'entrée"
|
||||
text: >
|
||||
Votre lampe-torche tremble dans la poussière de trois mille ans. Vous venez
|
||||
de percer le tombeau scellé du pharaon Sékhet-Râ, que nul n'a violé. Devant
|
||||
vous, un couloir descend, ses murs couverts de hiéroglyphes. Au sol, une
|
||||
ligne de sel et un avertissement gravé : « Qui prend sans donner ne ressort
|
||||
pas. » Votre guide a déjà fui.
|
||||
choices:
|
||||
- label: "Avancer dans le couloir aux hiéroglyphes"
|
||||
goto: couloir
|
||||
- label: "Étudier l'avertissement gravé"
|
||||
goto: avertissement
|
||||
|
||||
avertissement:
|
||||
screen: "L'avertissement"
|
||||
text: >
|
||||
En déchiffrant, vous comprenez : le tombeau est une balance. Pour chaque
|
||||
trésor pris, il faut offrir quelque chose en retour, ou la salle se referme.
|
||||
Une petite fiole d'eau et une plume sont posées dans une niche, comme des
|
||||
offrandes de départ.
|
||||
choices:
|
||||
- label: "Prendre la fiole et la plume, puis avancer"
|
||||
goto: couloir
|
||||
- label: "Ne rien toucher et avancer prudemment"
|
||||
goto: couloir
|
||||
|
||||
couloir:
|
||||
screen: "Le couloir"
|
||||
text: >
|
||||
Le couloir débouche sur une fourche. À gauche, une salle dont le sol est
|
||||
un damier de dalles gravées de symboles. À droite, un puits sombre d'où
|
||||
monte un cliquetis de scarabées. Devant, une lourde porte de pierre frappée
|
||||
d'une balance.
|
||||
choices:
|
||||
- label: "Traverser la salle au sol en damier"
|
||||
goto: damier
|
||||
- label: "Descendre le puits aux scarabées"
|
||||
goto: puits
|
||||
- label: "Forcer la porte à la balance"
|
||||
goto: balance
|
||||
|
||||
damier:
|
||||
screen: "Le damier"
|
||||
text: >
|
||||
Chaque dalle porte un symbole : eau, soleil, oiseau, serpent. Une fresque
|
||||
au mur montre l'ordre sacré : « Suis le Nil — l'eau, puis l'oiseau qui le
|
||||
survole, puis le soleil qui le sèche. » Marcher sur la mauvaise dalle, et
|
||||
des pointes jaillissent. La sortie est de l'autre côté.
|
||||
choices:
|
||||
- label: "Marcher : eau, oiseau, soleil"
|
||||
goto: tresor
|
||||
- label: "Marcher au hasard, en courant"
|
||||
goto: fin_pointes
|
||||
|
||||
puits:
|
||||
screen: "Le puits"
|
||||
text: >
|
||||
Vous descendez. Le fond grouille de scarabées dorés — vivants, ou
|
||||
mécaniques ? Au centre, sur un socle, repose un cœur d'or, le légendaire
|
||||
Cœur de Sékhet-Râ. Le prendre déclencherait sûrement les insectes. Un
|
||||
conduit d'aération, étroit, remonte vers la lumière.
|
||||
choices:
|
||||
- label: "Saisir le Cœur d'or"
|
||||
goto: coeur
|
||||
- label: "Déposer la plume en offrande, puis le prendre"
|
||||
goto: offrande
|
||||
- label: "Renoncer et remonter au couloir"
|
||||
goto: couloir
|
||||
|
||||
coeur:
|
||||
screen: "Le Cœur d'or"
|
||||
text: >
|
||||
Vos doigts se referment sur le Cœur d'or. Aussitôt, les scarabées
|
||||
s'animent en une marée bruissante et grimpante. Le conduit d'aération est
|
||||
votre seule issue, mais ramper avec le trésor vous ralentira.
|
||||
choices:
|
||||
- label: "Fuir par le conduit, le Cœur serré contre vous"
|
||||
goto: fin_scarabees
|
||||
- label: "Lâcher le Cœur pour sauver votre vie"
|
||||
goto: offrande
|
||||
|
||||
offrande:
|
||||
screen: "L'offrande"
|
||||
text: >
|
||||
Vous posez la plume sur le socle avant de prendre le Cœur — un échange,
|
||||
comme l'exige la balance. Les scarabées s'immobilisent, apaisés. Le Cœur
|
||||
d'or pulse faiblement dans votre main, tiède. Le conduit remonte vers la
|
||||
salle de la balance.
|
||||
choices:
|
||||
- label: "Remonter vers la porte à la balance"
|
||||
goto: balance
|
||||
|
||||
tresor:
|
||||
screen: "La chambre au trésor"
|
||||
text: >
|
||||
Vous franchissez le damier sans encombre. La chambre regorge d'or, de
|
||||
lapis-lazuli, de masques funéraires. Au centre, sur un coussin de poussière,
|
||||
l'Œil d'Horus, scintillant. Mais une seconde porte, scellée d'une balance,
|
||||
semble être la vraie sortie.
|
||||
choices:
|
||||
- label: "Empocher l'Œil d'Horus et gagner la porte"
|
||||
goto: balance
|
||||
- label: "Ne prendre que l'Œil, laisser le reste"
|
||||
goto: balance
|
||||
- label: "Tout emporter, les bras chargés"
|
||||
goto: fin_avidite
|
||||
|
||||
balance:
|
||||
screen: "La salle de la balance"
|
||||
text: >
|
||||
Une vaste salle. Au centre, une balance de pierre à deux plateaux, gardée
|
||||
par une statue d'Anubis aux yeux de rubis. Une voix d'outre-tombe résonne :
|
||||
« Pose sur le plateau ce que vaut ta vie. Si ton cœur est plus léger qu'une
|
||||
plume, tu passes. Sinon, tu restes. » Le plateau vide attend.
|
||||
choices:
|
||||
- label: "Poser le trésor pris sur le plateau"
|
||||
goto: fin_pesee
|
||||
- label: "Poser la plume (si vous l'avez gardée)"
|
||||
goto: fin_plume
|
||||
- label: "Poser votre lampe, votre seul vrai bien ici"
|
||||
goto: fin_lampe
|
||||
- label: "Refuser et tenter de fracasser la statue"
|
||||
goto: fin_anubis
|
||||
|
||||
# ── Fins ──
|
||||
fin_pointes:
|
||||
screen: "Le damier fatal"
|
||||
text: >
|
||||
Votre pied se pose sur le serpent. Un déclic — et le sol entier hérisse ses
|
||||
pointes. Vous devenez, vous aussi, une relique du tombeau de Sékhet-Râ, que
|
||||
le prochain pilleur, dans mille ans, étudiera avec respect. Fin.
|
||||
|
||||
fin_scarabees:
|
||||
screen: "La marée dorée"
|
||||
text: >
|
||||
Vous rampez, le Cœur contre la poitrine, mais les scarabées sont plus
|
||||
rapides. Ils vous recouvrent en une armure vivante et bruissante. Le Cœur
|
||||
d'or roule au fond du puits, attendant patiemment le prochain imprudent.
|
||||
Vous avez pris sans donner. Fin.
|
||||
|
||||
fin_avidite:
|
||||
screen: "L'avidité"
|
||||
text: >
|
||||
Les bras chargés d'or, vous atteignez la balance — mais elle s'effondre
|
||||
sous le poids de votre cupidité. La chambre se referme dans un grondement
|
||||
de pierre. Vous mourrez riche, dans le noir, sur un trésor que personne ne
|
||||
reverra. Fin.
|
||||
|
||||
fin_pesee:
|
||||
screen: "La pesée"
|
||||
text: >
|
||||
Vous posez l'or sur le plateau. La balance penche lourdement : votre butin
|
||||
pèse plus qu'une vie honnête. Anubis incline la tête. « Tu as choisi l'or
|
||||
plutôt que le souffle. » La salle se scelle. Vous restez, gardien d'un
|
||||
trésor que vous ne dépenserez jamais. Fin.
|
||||
|
||||
fin_plume:
|
||||
screen: "Plus léger qu'une plume"
|
||||
text: >
|
||||
Vous posez la plume sur le plateau. L'autre plateau, celui de votre cœur,
|
||||
remonte doucement : vous avez donné autant que pris. Anubis baisse ses yeux
|
||||
de rubis. La porte scellée coulisse sur le soleil du désert. Vous ressortez
|
||||
vivant, le Cœur d'or au poing — et la conscience légère. Fin.
|
||||
|
||||
fin_lampe:
|
||||
screen: "Le sacrifice de lumière"
|
||||
text: >
|
||||
Vous posez votre lampe sur le plateau — votre seule lumière, votre seul
|
||||
réconfort. Anubis semble surpris par ce geste désintéressé. La porte
|
||||
s'ouvre. Vous ressortez à tâtons, dans le noir puis dans l'aube, les mains
|
||||
vides mais l'âme entière. Le vrai trésor, c'était de ressortir. Fin.
|
||||
|
||||
fin_anubis:
|
||||
screen: "La colère d'Anubis"
|
||||
text: >
|
||||
Vous frappez la statue. Les yeux de rubis s'embrasent ; le sable se met à
|
||||
monter, par le sol, par les murs, comme un sablier retourné. En quelques
|
||||
souffles, la salle entière n'est plus qu'une dune lisse. On ne pille pas
|
||||
Anubis. Fin.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Livre dont vous êtes le héros — L'Île aux Mille Échos
|
||||
# Navigation D-pad (curseur ">" + clic). passages {screen, text,
|
||||
# choices:[{label, goto}]}. Passage sans choices = fin.
|
||||
|
||||
title: "L'Île aux Mille Échos"
|
||||
voice: Thomas
|
||||
start: plage
|
||||
|
||||
passages:
|
||||
plage:
|
||||
screen: "La plage"
|
||||
text: >
|
||||
Vous reprenez connaissance sur une plage de sable noir, les poumons
|
||||
brûlants d'eau de mer. De votre voilier, il ne reste qu'une planche et un
|
||||
coffre échoué. Devant vous, une jungle dense ; à droite, des falaises
|
||||
percées de grottes ; à gauche, une crique. Quand vous toussez, la jungle
|
||||
vous répond : « …toussez… toussez… » d'une voix qui n'est pas la vôtre.
|
||||
choices:
|
||||
- label: "Ouvrir le coffre échoué"
|
||||
goto: coffre
|
||||
- label: "Entrer dans la jungle"
|
||||
goto: jungle
|
||||
- label: "Explorer les grottes des falaises"
|
||||
goto: grottes
|
||||
|
||||
coffre:
|
||||
screen: "Le coffre"
|
||||
text: >
|
||||
Le coffre contient une gourde, un couteau, un briquet à amadou et une
|
||||
carte trempée. La carte montre l'île : une jungle, des grottes, un phare
|
||||
au sommet, et au centre une spirale annotée « NE PARLE PAS DANS LE PUITS ».
|
||||
Une plume y est glissée, comme un marque-page.
|
||||
choices:
|
||||
- label: "Partir vers le phare par la jungle"
|
||||
goto: jungle
|
||||
- label: "Aller droit aux grottes"
|
||||
goto: grottes
|
||||
|
||||
jungle:
|
||||
screen: "La jungle"
|
||||
text: >
|
||||
La jungle bruisse de mille échos décalés : vos propres pas vous reviennent
|
||||
avec un temps de retard, comme si quelqu'un marchait derrière vous. Un
|
||||
sentier monte vers une lumière — sans doute le phare. Une trouée latérale
|
||||
descend vers un puits de pierre, au centre de l'île.
|
||||
choices:
|
||||
- label: "Monter le sentier vers le phare"
|
||||
goto: phare
|
||||
- label: "Descendre vers le puits central"
|
||||
goto: puits
|
||||
- label: "Appeler à l'aide de toutes vos forces"
|
||||
goto: appel
|
||||
|
||||
appel:
|
||||
screen: "L'appel"
|
||||
text: >
|
||||
« Ohé ! Y a quelqu'un ?! » Votre cri se démultiplie, revient de partout,
|
||||
enfle au lieu de s'éteindre — dix voix, cent voix, toutes la vôtre, qui se
|
||||
mettent à répondre des choses que vous n'avez pas dites. La jungle frémit.
|
||||
Quelque chose, nourri de ces voix, se rapproche.
|
||||
choices:
|
||||
- label: "Vous taire et courir vers le phare"
|
||||
goto: phare
|
||||
- label: "Vous taire et vous cacher dans les grottes"
|
||||
goto: grottes
|
||||
|
||||
grottes:
|
||||
screen: "Les grottes"
|
||||
text: >
|
||||
Les grottes sont une cathédrale d'échos. Le moindre bruit s'y répète à
|
||||
l'infini, de plus en plus net, de plus en plus humain. Des peintures
|
||||
pariétales montrent des silhouettes qui se bouchent les oreilles. Un
|
||||
boyau descend vers un grondement d'eau ; une corniche mène vers la lumière
|
||||
du dehors et le sentier du phare.
|
||||
choices:
|
||||
- label: "Suivre le boyau vers l'eau"
|
||||
goto: source
|
||||
- label: "Regagner la corniche et viser le phare"
|
||||
goto: phare
|
||||
- label: "Graver votre nom dans la roche"
|
||||
goto: fin_gravure
|
||||
|
||||
source:
|
||||
screen: "La source"
|
||||
text: >
|
||||
Le boyau débouche sur une source souterraine d'une clarté irréelle. À sa
|
||||
surface flotte un radeau de roseaux, déjà construit, et un message gravé
|
||||
au-dessus : « D'autres ont fui par l'eau. L'île ne garde que les voix. »
|
||||
Le courant file vers une issue lumineuse, vers la mer.
|
||||
choices:
|
||||
- label: "Embarquer sur le radeau et fuir par l'eau"
|
||||
goto: fin_radeau
|
||||
- label: "Remonter, déterminé à percer le secret du puits"
|
||||
goto: puits
|
||||
|
||||
phare:
|
||||
screen: "Le phare"
|
||||
text: >
|
||||
Au sommet, un phare éteint depuis longtemps. La lanterne est intacte ; il
|
||||
manque juste une flamme. De là-haut, vous voyez tout : la spirale du puits
|
||||
au centre, et au large… un navire, minuscule, qui passe sans vous voir.
|
||||
Avec le briquet et l'huile de la lampe, vous pourriez rallumer le phare.
|
||||
choices:
|
||||
- label: "Rallumer le phare pour appeler le navire"
|
||||
goto: fin_phare
|
||||
- label: "Crier vers le navire de toutes vos forces"
|
||||
goto: fin_cri
|
||||
- label: "Descendre d'abord élucider le puits"
|
||||
goto: puits
|
||||
|
||||
puits:
|
||||
screen: "Le puits"
|
||||
text: >
|
||||
Au cœur de l'île, un puits en spirale s'enfonce dans le noir. L'air en
|
||||
remonte chargé de chuchotements — toutes les voix prisonnières de l'île,
|
||||
celles de tous les naufragés. Une inscription, encore : « NE PARLE PAS.
|
||||
Écoute. Et si tu dois parler, dis ton nom, une seule fois, pour le
|
||||
reprendre. »
|
||||
choices:
|
||||
- label: "Vous pencher et écouter en silence"
|
||||
goto: ecoute
|
||||
- label: "Crier dans le puits pour défier l'île"
|
||||
goto: fin_avalee
|
||||
- label: "Prononcer votre nom, une seule fois"
|
||||
goto: fin_nom
|
||||
|
||||
ecoute:
|
||||
screen: "L'écoute"
|
||||
text: >
|
||||
Vous écoutez, sans un mot. Peu à peu, le chœur des voix dessine une
|
||||
vérité : l'île n'est pas vivante, elle est affamée de sons, et elle rend
|
||||
qui lui rend le silence. Une des voix, plus claire, vous souffle : « La
|
||||
cloche du phare. Sonne-la une fois, et pars sans un mot. »
|
||||
choices:
|
||||
- label: "Remonter sonner la cloche du phare"
|
||||
goto: fin_cloche
|
||||
- label: "Tenter malgré tout la fuite par l'eau"
|
||||
goto: source
|
||||
|
||||
# ── Fins ──────────────────────────────────────────────────────────────────
|
||||
fin_gravure:
|
||||
screen: "Un nom de plus"
|
||||
text: >
|
||||
Vous gravez votre nom dans la roche, à côté des centaines d'autres. À
|
||||
peine la dernière lettre tracée, votre voix vous quitte, aspirée par
|
||||
l'écho. Vous devenez l'un de ces murmures qui, un jour, souffleront un
|
||||
conseil à un nouveau naufragé. L'île vous a gardé. Fin.
|
||||
|
||||
fin_radeau:
|
||||
screen: "Par l'eau"
|
||||
text: >
|
||||
Vous vous laissez glisser sur le radeau. Le courant souterrain vous crache
|
||||
en pleine mer, sous les étoiles. Derrière vous, l'île s'éloigne, et son
|
||||
chœur de voix s'éteint enfin dans le ressac. Trois jours plus tard, un
|
||||
chalutier vous repêche, muet d'épuisement mais vivant. Vous avez fui. Fin.
|
||||
|
||||
fin_phare:
|
||||
screen: "La lumière"
|
||||
text: >
|
||||
La flamme prend, la lentille s'embrase : un faisceau balaie la mer. Le
|
||||
navire vire de bord. Tandis que la chaloupe approche, le phare semble enfin
|
||||
respirer, et les échos de l'île s'apaisent, comme rassasiés d'avoir, pour
|
||||
une fois, servi à sauver quelqu'un. On vous hisse à bord. Sauvé. Fin.
|
||||
|
||||
fin_cri:
|
||||
screen: "Le cri de trop"
|
||||
text: >
|
||||
Vous hurlez vers le navire. L'île saisit votre cri au vol, le multiplie,
|
||||
le retourne — et le navire, croyant à un piège de sirènes, met les voiles.
|
||||
Votre voix, elle, ne revient pas : elle reste là-haut, à appeler des bateaux
|
||||
qui fuiront toujours. Vous, vous restez aussi. Fin.
|
||||
|
||||
fin_avalee:
|
||||
screen: "Avalé par l'écho"
|
||||
text: >
|
||||
Votre défi résonne dans la spirale, descend, descend, et remonte
|
||||
centuplé, assourdissant, jusqu'à vous arracher du sol. Le puits vous avale
|
||||
comme une dernière syllabe. Quelque part, très loin, un futur naufragé
|
||||
entendra votre cri et croira que l'île est vivante. Fin.
|
||||
|
||||
fin_nom:
|
||||
screen: "Reprendre son nom"
|
||||
text: >
|
||||
Vous prononcez votre nom, une seule fois, fermement. Les voix se figent,
|
||||
surprises — puis vous le rendent, intact, et avec lui un sentier de pierre
|
||||
qui n'existait pas, droit vers la plage et un canot oublié. L'île, qui ne
|
||||
retient que les noms perdus, vous laisse partir avec le vôtre. Fin.
|
||||
|
||||
fin_cloche:
|
||||
screen: "Une seule cloche"
|
||||
text: >
|
||||
Vous remontez et sonnez la cloche du phare : un coup, grave et pur. L'écho,
|
||||
pour la première fois, ne se démultiplie pas — il s'éteint, net, comme un
|
||||
souffle qu'on relâche. Dans ce silence neuf, vous descendez à la plage,
|
||||
poussez le canot, et ramez sans un mot. Derrière vous, l'île se tait
|
||||
enfin. Fin.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Livre dont vous êtes le héros — Le Manoir de Minuit (épouvante gothique)
|
||||
title: "Le Manoir de Minuit"
|
||||
voice: Thomas
|
||||
start: grille
|
||||
|
||||
passages:
|
||||
grille:
|
||||
screen: "La grille"
|
||||
text: >
|
||||
Votre voiture est tombée en panne sous l'orage, juste devant le manoir
|
||||
Ravenholt. Les fenêtres sont noires, sauf une, tout en haut, où vacille
|
||||
une bougie. La grille rouillée n'est pas fermée. Un heurtoir en forme de
|
||||
main vous attend sur la porte. Minuit sonne quelque part, treize fois.
|
||||
choices:
|
||||
- label: "Frapper avec le heurtoir"
|
||||
goto: hall
|
||||
- label: "Faire le tour vers la fenêtre éclairée"
|
||||
goto: serre
|
||||
|
||||
hall:
|
||||
screen: "Le grand hall"
|
||||
text: >
|
||||
La porte s'ouvre seule. Le hall est tendu de portraits dont les yeux
|
||||
semblent suivre vos pas. Un escalier monte vers la bougie ; à droite, une
|
||||
bibliothèque ; sous l'escalier, une porte de cave d'où monte un courant
|
||||
d'air froid et une odeur de terre.
|
||||
choices:
|
||||
- label: "Monter vers la chambre éclairée"
|
||||
goto: chambre
|
||||
- label: "Entrer dans la bibliothèque"
|
||||
goto: bibliotheque
|
||||
- label: "Descendre à la cave"
|
||||
goto: cave
|
||||
|
||||
serre:
|
||||
screen: "La serre"
|
||||
text: >
|
||||
Derrière le manoir, une serre brisée déborde de roses noires qui fleurissent
|
||||
en plein hiver. Une jeune fille en robe ancienne y cueille des fleurs, vous
|
||||
tournant le dos. « Vous êtes en retard, dit-elle sans se retourner. Le
|
||||
dîner refroidit depuis cent ans. » Une porte vitrée mène à la salle à
|
||||
manger.
|
||||
choices:
|
||||
- label: "Lui demander qui elle est"
|
||||
goto: fille
|
||||
- label: "Entrer dans la salle à manger"
|
||||
goto: diner
|
||||
|
||||
fille:
|
||||
screen: "Eléonore"
|
||||
text: >
|
||||
Elle se retourne : un beau visage, mais aux yeux entièrement noirs. « Je
|
||||
suis Eléonore Ravenholt. Le manoir nous garde, ma famille et moi, depuis
|
||||
l'incendie. Il lui faut un invité, chaque nuit, pour rester en vie.
|
||||
Aujourd'hui, c'est vous. » Elle vous tend une rose noire. « Garde-la. Elle
|
||||
te protégera. Une fois. »
|
||||
choices:
|
||||
- label: "Prendre la rose et entrer dîner"
|
||||
goto: diner
|
||||
- label: "Refuser et fuir vers le hall"
|
||||
goto: hall
|
||||
|
||||
bibliotheque:
|
||||
screen: "La bibliothèque"
|
||||
text: >
|
||||
Des milliers de livres, et un seul ouvert sur un lutrin : le registre du
|
||||
manoir. Chaque page porte le nom d'un invité, et une date de mort. La
|
||||
dernière ligne est vierge — elle attend un nom. Une plume trempe dans un
|
||||
encrier rouge. Une cheminée éteinte cache peut-être un passage.
|
||||
choices:
|
||||
- label: "Fouiller la cheminée"
|
||||
goto: passage
|
||||
- label: "Écrire un autre nom que le vôtre"
|
||||
goto: fin_nom_ecrit
|
||||
- label: "Refermer le registre et monter"
|
||||
goto: chambre
|
||||
|
||||
cave:
|
||||
screen: "La cave"
|
||||
text: >
|
||||
La cave est un caveau. Des cercueils alignés, dont les plaques portent les
|
||||
noms du registre. L'un d'eux est ouvert, vide, votre taille exactement. Au
|
||||
mur, des outils anciens et un crucifix de fer descellé. Un soupirail, tout
|
||||
en haut, laisse passer la pluie — et peut-être un corps.
|
||||
choices:
|
||||
- label: "Arracher le crucifix de fer"
|
||||
goto: crucifix
|
||||
- label: "Vous hisser par le soupirail"
|
||||
goto: fin_soupirail
|
||||
- label: "Remonter au hall"
|
||||
goto: hall
|
||||
|
||||
chambre:
|
||||
screen: "La chambre"
|
||||
text: >
|
||||
En haut, une chambre d'enfant figée dans le temps, la bougie brûlant sans
|
||||
fondre. Dans le miroir, votre reflet a un sourire que vous n'avez pas. Une
|
||||
vieille horloge marque minuit moins une. Sur le lit, un médaillon ouvert
|
||||
montre toute la famille Ravenholt — et, au fond, une silhouette
|
||||
supplémentaire : vous.
|
||||
choices:
|
||||
- label: "Briser le miroir au reflet menteur"
|
||||
goto: fin_miroir
|
||||
- label: "Empocher le médaillon et redescendre"
|
||||
goto: diner
|
||||
- label: "Souffler la bougie qui ne fond pas"
|
||||
goto: fin_bougie
|
||||
|
||||
diner:
|
||||
screen: "Le dîner"
|
||||
text: >
|
||||
Une table dressée pour treize, des mets fumants, la famille Ravenholt
|
||||
attablée, translucide et souriante. La place du bout vous est réservée.
|
||||
« Mange avec nous, murmurent-ils d'une seule voix, et reste à jamais. Ou
|
||||
porte un toast, et l'un de nous prendra ta place. » Un verre de vin sombre
|
||||
vous attend.
|
||||
choices:
|
||||
- label: "Manger et rejoindre la famille"
|
||||
goto: fin_festin
|
||||
- label: "Porter un toast à Eléonore, la rose au poing"
|
||||
goto: fin_toast
|
||||
- label: "Renverser la table et courir au passage"
|
||||
goto: passage
|
||||
|
||||
passage:
|
||||
screen: "Le passage"
|
||||
text: >
|
||||
Derrière la cheminée, un escalier secret descend vers les fondations. Au
|
||||
bout, le cœur du manoir : un grand cierge noir, gros comme un homme, qui
|
||||
brûle d'une flamme froide. C'est lui qui tient le manoir entre deux mondes.
|
||||
Le crucifix de fer, s'il vous l'avez, pourrait l'éteindre.
|
||||
choices:
|
||||
- label: "Éteindre le cierge avec le crucifix"
|
||||
goto: fin_crucifix
|
||||
- label: "Souffler le cierge à mains nues"
|
||||
goto: fin_bougie
|
||||
- label: "Faire demi-tour, ce lieu vous glace"
|
||||
goto: hall
|
||||
|
||||
crucifix:
|
||||
screen: "Le crucifix"
|
||||
text: >
|
||||
Le crucifix de fer est lourd et glacé dans votre main. Les murs de la cave
|
||||
gémissent, comme si le manoir savait. Un escalier dérobé, derrière les
|
||||
cercueils, semble mener au cœur des fondations.
|
||||
choices:
|
||||
- label: "Descendre vers les fondations"
|
||||
goto: passage
|
||||
- label: "Remonter affronter le dîner"
|
||||
goto: diner
|
||||
|
||||
# ── Fins ──
|
||||
fin_nom_ecrit:
|
||||
screen: "Le nom écrit"
|
||||
text: >
|
||||
Vous trempez la plume et écrivez le premier nom venu — celui d'un ennemi
|
||||
lointain. L'encre rouge fume. Très loin, quelqu'un que vous n'aimiez pas
|
||||
rend l'âme à votre place, et la ligne du registre se referme sur lui. Vous
|
||||
sortez libre, à l'aube. Mais ce nom-là pèsera sur vous chaque nuit. Fin.
|
||||
|
||||
fin_soupirail:
|
||||
screen: "La fuite par le soupirail"
|
||||
text: >
|
||||
Vous vous hissez, la pluie vous gifle, vos ongles s'arrachent — puis l'herbe
|
||||
détrempée du parc. Vous courez sans vous retourner jusqu'à la route. Au
|
||||
matin, on ne retrouvera aucun manoir à cet endroit, seulement des ruines
|
||||
calcinées depuis un siècle. Vous, vous êtes bien vivant. Fin.
|
||||
|
||||
fin_miroir:
|
||||
screen: "Le miroir brisé"
|
||||
text: >
|
||||
Vous fracassez le miroir. Le reflet hurle — un cri qui n'est pas le vôtre —
|
||||
et avec lui, tout le manoir se fissure comme du verre. Les Ravenholt
|
||||
s'effacent, libérés. Vous vous réveillez dans votre voiture, à l'aube, le
|
||||
moteur qui tourne enfin. Était-ce un rêve ? Vos mains saignent de mille
|
||||
éclats. Fin.
|
||||
|
||||
fin_bougie:
|
||||
screen: "La flamme froide"
|
||||
text: >
|
||||
Vous soufflez la flamme froide. L'obscurité tombe, totale, vivante. Quand
|
||||
la lumière revient, vous êtes attablé au dîner, translucide et souriant, la
|
||||
place du bout enfin occupée. Le manoir attend déjà le prochain invité en
|
||||
retard. Fin.
|
||||
|
||||
fin_festin:
|
||||
screen: "Le festin éternel"
|
||||
text: >
|
||||
Le premier morceau a un goût de cendre et de siècles. À la dernière
|
||||
bouchée, votre reflet dans la coupe est devenu translucide. Vous prenez
|
||||
place parmi les Ravenholt, souriant pour toujours, attendant le prochain
|
||||
voyageur en panne sous l'orage. Fin.
|
||||
|
||||
fin_toast:
|
||||
screen: "Le toast"
|
||||
text: >
|
||||
Vous levez votre verre vers Eléonore et brandissez la rose noire. Elle
|
||||
sourit, presque triste. « Tu as compris. » L'un des convives translucides
|
||||
se lève à votre place et s'assoit, libéré. La rose se fane d'un coup. Les
|
||||
portes s'ouvrent sur l'aube. Vous partez, vivant — un seul usage, elle vous
|
||||
l'avait dit. Fin.
|
||||
|
||||
fin_crucifix:
|
||||
screen: "Le cierge éteint"
|
||||
text: >
|
||||
Le fer touche la flamme froide : elle s'éteint dans un long soupir de tout
|
||||
le bâtiment. Le manoir, privé de son cœur, retourne à la poussière qu'il
|
||||
aurait dû être depuis cent ans. Les Ravenholt vous remercient d'un dernier
|
||||
regard apaisé avant de s'effacer. Vous sortez sous un ciel qui s'éclaircit.
|
||||
Fin.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user