From 22ef7143fab78b03edf1a21ad354118fbbb43bf8 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Fri, 12 Jun 2026 15:09:37 +0800 Subject: [PATCH 01/15] Add design spec: self-built TypeScript harness + web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the OpenClaw + Discord runtime direction with a transport- agnostic engine (Node/Fastify) speaking zod-typed JSON events over WebSocket to a React web room client. Covers protocol, turn loop, code/prompt split, rooms/identity/persistence, content migration, testing, and milestones. Design was adversarially reviewed by a multi-agent workflow; adjudicated decisions recorded in §9. Co-Authored-By: Claude Fable 5 --- .../specs/2026-06-12-online-harness-design.md | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-online-harness-design.md diff --git a/docs/superpowers/specs/2026-06-12-online-harness-design.md b/docs/superpowers/specs/2026-06-12-online-harness-design.md new file mode 100644 index 0000000..7590740 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-online-harness-design.md @@ -0,0 +1,347 @@ +# Online Harness Design — Self-Built TypeScript Server + Web Client + +**Date:** 2026-06-12 +**Status:** Approved (design review with owner; multi-agent adversarial review applied) +**Supersedes:** the OpenClaw + Discord runtime for all new development. `workspace/` stays frozen and functional until cutover. + +## 1. Context & Goals + +The current system is a context-engineering stack (prompts, skills, modules, markdown memory) that runs on OpenClaw over Discord. Direction already locked: leave both OpenClaw and Discord; build a self-owned harness aimed at a public product. + +**Goals** + +- Transport-agnostic GM engine. Clients speak to the server only via JSON events over WebSocket; the first client is a minimal web room (join by code), which grows into the product's own UI. +- Reuse the existing content stack (SOUL.md, RULEBOOK.md, modules, memory model) with minimal mechanical transformation. +- Cheap unit economics: OpenAI-compatible Chinese models (DeepSeek first), prompt-cache-friendly context assembly. +- Buildable and sustainable by a solo developer who is learning software development. Mainstream choices everywhere; YAGNI ruthlessly; every deferred feature gets a named seam. + +**Non-goals (v0)** + +User accounts, payments, moderation backends, multi-process scaling, battle map, voice, native clients. Each has a designated seam (§6.6, §10). + +**UI roadmap** (protocol reserves room for all from day 1; implementation is phased): dice visualization → live character sheet panel → AI scene art/portraits → battle map. + +## 2. Architecture Overview + +pnpm monorepo **inside this repo** (engine schemas and module content must move in lockstep; one atomic commit, no cross-repo version dance): + +``` +game-master/ + pnpm-workspace.yaml # packages: ["packages/*"] + package.json # root scripts only (private: true) + tsconfig.base.json # strict: true + .env.example # PORT, DATA_DIR, CONTENT_DIR, LLM_* keys + packages/ + protocol/ # @gm/protocol — wire contract; zod is its ONLY dependency + server/ # @gm/server — Fastify + ws + static; owns engine/ + src/ + index.ts # process entry + config.ts # zod-validated process.env, fail fast + http/ # routes, ws handler, room registry (transport layer) + engine/ # TRANSPORT-AGNOSTIC. Imports @gm/protocol + node:fs ONLY, + turn.ts # never fastify/ws. This rule is the portability guarantee. + context.ts # context assembly keyed on engine-state booleans (§5.3) — + tools/ # note: NOT an intent router; that concept is deleted + module/ # module loader, scene FSM, evaluateCompletion() + memory/ # per-table store (JSON state + markdown narrative) + llm/ # LLMClient interface, OpenAI-compatible impl, FakeLLM + gate.ts # subscription gate stub: checkGate() before every LLM turn + web/ # @gm/web — React + Vite + Tailwind + zustand + content/ # engine-consumable data (migration output, §8) + modules/ prompts/ srd/ + data/ # runtime state, gitignored: data/tables// + workspace/ # legacy OpenClaw deployable; frozen; deleted at cutover + scripts/migrate-modules.ts # one-time content transform (§8) +``` + +**Key dependencies** (each the most mainstream choice in its niche): Fastify + `@fastify/websocket` + `@fastify/static`; `zod@^4`; official `openai` npm SDK with custom `baseURL`; `gray-matter` for frontmatter; `vitest`; `zustand` (one `applyEvent(event)` reducer over the WS stream); plain Tailwind first, cherry-pick shadcn/ui later; `tsx watch` for dev, `tsup` for prod build; Node 22 `--env-file`; `pino` (built into Fastify). + +`@gm/protocol` is consumed **as source** (no build step of its own) — removes the stale-built-artifact monorepo failure mode. + +**Deployment:** one Node process serves API + WS + built SPA. Small VPS; `DATA_DIR` on persistent disk; backup = cron `tar` (flat files by design). Dev = two processes (`tsx watch` + Vite HMR with `/ws` + `/api` proxy). + +## 3. Event Protocol (`packages/protocol`) + +The protocol package is the **single source of truth for everything on the wire**. Server and client import the same zod schemas. Where earlier drafts of subsystem designs named events differently, the names below are canonical. + +### 3.1 Envelope + +```ts +const Envelope = z.object({ + v: z.literal(1), // bump ONLY for breaking changes; goal: never + id: z.string(), // UUID; c2s client-generated, s2c server-generated + type: z.string(), // "namespace.action" + room: z.string().nullable(), // null only pre-join / on errors + seq: z.number().int().positive().optional(), + // ONLY on s2c room broadcasts; per-room monotonic + ts: z.number().int(), // epoch ms, server-authoritative + payload: z.unknown(), +}); +``` + +Two standing rules make additive evolution non-breaking by construction: +1. Unknown `type` with a valid envelope → silently ignore. +2. Both sides tolerate unknown payload keys (zod strip). + +### 3.2 Ordering & recovery — snapshot-only (decision) + +- s2c broadcasts carry per-room `seq`. Client: `seq <= lastSeq` discard; `== lastSeq+1` apply; gap → recover. +- **One recovery path for fresh join, reconnect, and seq gap:** send `room.join` (with stored token) → server replies `room.joined` then `room.snapshot` carrying the room's current `seq` watermark → client sets `lastSeq = snapshot.seq` and re-renders from the snapshot. **No event-log replay to clients, ever.** The on-disk transcript (§6.4) is a record, never a client-facing catch-up source. (Adjudicated: replay infra — retention, transient-event semantics, seq regression after crash — is exactly the bug class a solo learning dev should not build; snapshot-reset also makes client `lastSeq` always server-anchored, which neutralizes crash-time seq regression.) +- **Join atomicity invariant:** inside the room actor, capture watermark + serialize snapshot + subscribe the socket as one atomic operation; no broadcast may interleave. (Prevents the unrecoverable-gap reconnect loop.) +- Mid-stream rejoin: `room.snapshot.activeStream` carries `{messageId, kind, textSoFar}`. +- No per-player idempotency LRU and no per-delta index in v0 (cut as redundant with seq + `gm.streamEnd` full-text self-heal). + +### 3.3 v0 event catalogue (~20 types) + +Client → server: +- `room.join { roomCode, displayName, playerToken? }` — also the reconnect/gap-recovery path. Token resumes the seat (then `displayName` ignored); unknown room → `error{ROOM_NOT_FOUND}`; name conflict → `error{NAME_TAKEN}`. +- `room.leave {}` — presence offline + UI exit. **Does NOT sever the token↔seat binding** (load-bearing for account-less identity; adjudicated in favor of the persistence design). +- `room.close {}` — host only; ends the table. +- `chat.send { text (≤2000), channel: "ic"|"ooc" }` — IC = player action/speech, feeds the GM turn; OOC = table talk. Character attribution derived server-side; no spoofing field on the wire. +- `dice.roll` — discriminated union: `{ requestId }` (answer a pending GM request; server uses the *stored* expression/mode/DC) **xor** `{ expression, mode?, reason? }` (free roll). Both present is schema-invalid. +- `system.ping {}` + +Server → client: +- `room.joined { roomCode, playerId, playerToken, resumed, isHost, protocolVersion }` *(sender only)* +- `room.snapshot { seq, roomName, players, scene, characters, pendingRollRequests, transcript(≤100), activeStream, gmStatus }` *(sender only; the only recovery mechanism)* +- `room.closed { reason }` *(broadcast)* +- `presence.update { players }` *(broadcast; FULL roster every time — no deltas, no merge logic)* +- `chat.message { messageId, clientId, playerId, displayName, channel, characterName, text }` *(broadcast; `clientId` echoes the sender's envelope id for optimistic-UI reconciliation)* +- `gm.status { state: "idle"|"thinking"|"streaming" }` *(broadcast)* +- `gm.streamStart { messageId, kind }` / `gm.streamDelta { messageId, text }` / `gm.streamEnd { messageId, kind, text, aborted }` *(broadcast; `streamEnd.text` is the FULL final text — self-heals any delta loss)* + - `kind ∈ {narration, dialogue, combat, recap, quest, loot, system}` (canonical enum; "dice" deliberately absent — dice presentation flows exclusively through `dice.requested`/`dice.rolled`) +- `dice.requested { requestId, playerId, characterName, expression, mode, reason, dc? }` *(broadcast; whole table sees the ask, only the target renders the button; `dc` present only when the engine chose to show it)* +- `dice.rolled { rollId, requestId?, roller{kind,playerId,name}, expression, mode, groups[{sides,rolls,kept}], modifiers, total, vsDc?, crit?, reason }` *(broadcast; server-authoritative full breakdown — the day-1 dice-viz data source)* +- `character.snapshot { character: CharacterSheet }` *(broadcast; ANY sheet change re-broadcasts the whole sheet — there is NO patch event; deep-merge semantics were cut as a divergence-bug generator at 6-player scale)* +- `scene.changed { sceneId, title, moduleId, moduleTitle }` *(broadcast)* +- `module.changed { moduleId, title, status: "started"|"completed" }` *(broadcast)* +- `system.notice { level, text, code? }`, `system.pong {}` +- `error { code, message, refId }` — codes: `BAD_REQUEST, ROOM_NOT_FOUND, ROOM_FULL, NAME_TAKEN, INVALID_TOKEN, FORBIDDEN, NOT_YOUR_REQUEST, REQUEST_ALREADY_RESOLVED, GATE_LIMIT, RATE_LIMITED, INTERNAL` + +**Room creation is HTTP, not WS** (decision): `POST /api/rooms { hostName, roomName? }` → `{ roomCode, playerId, token }` → client opens the socket and joins. This deletes the pre-join `room: null` special case and the `room.create/created` events. v0 collects **no table prefs** — `table.md` is rendered from the template with defaults (mixed tone, standard lethality); a prefs form is a later additive HTTP feature. No setup-wizard events exist. + +**Reserved namespaces, deliberately unschema'd:** `map.*`, `media.*`, `combat.*`, `session.*`. Reserving means: the names are documented as taken, and the ignore-unknown-types rule makes their later addition non-breaking. Writing their schemas now would be guessing (adjudicated: the same logic the original design correctly applied to `map.*` applies to media/combat too; `room.snapshot` gains optional fields for them later for free via zod strip). + +**Not in the protocol and why:** accounts/auth (token field is the seam), billing ops (gate results surface as `error{GATE_LIMIT}`/`system.notice{UPSELL}` only; gate runs server-side before any LLM call — never model- or client-influenced), raw LLM traffic (providers stay swappable; prompts stay private), analytics, voice, file upload, message edit/delete/reactions/typing, history pagination (snapshot tail + markdown memory are the record; infinite scrollback would be an HTTP GET later). + +### 3.4 CharacterSheet (wire format) + +Mirrors the character template's mechanical sections: identity (name/race/class/level/background), `hp{current,max,temp}`, ac, speed, initiativeMod, passivePerception, abilities, profBonus, saves, skills, conditions, inventory, gold, spellSlots, features, portraitMediaId, status (`alive|retired|dead|missing` — table.md's enum wins over the template's "Active"). The markdown sheet on disk remains a superset (backstory, personality, relationships, XP log); the character store owns the markdown↔wire mapping. v0 uses milestone leveling driven by module-completion code; an `xp` field is additive later. + +## 4. GM Engine — Turn Loop + +### 4.1 The code/prompt split (the architectural payoff) + +OpenClaw forced everything to be prompts. The harness splits: + +**Becomes code:** dice (server CSPRNG — "never fudge" is now structural), flag validation, scene-transition legality, completion evaluation, session/window compaction *trigger*, subscription gate (fail-closed by construction: `checkGate()` runs before any LLM turn is enqueued), telemetry, Discord-UX formatting (dead — replaced by `kind`-tagged events + client rendering). + +**Stays prompt (in `content/prompts/`):** SOUL.md voice (near-verbatim, two sentence-level rewordings, §8), merged rulebook, safety-boundaries fragment, module rails/derailment guidance, combat narration style, character-creation framing, recap/compaction instructions. + +### 4.2 Concurrency: one turn per room, mailbox coalescing + +- Player IC message → broadcast `chat.message` immediately (chat echo never blocked by GM latency) → append to the room mailbox. +- One in-flight GM turn per room (per-room promise-chain mutex). Messages arriving mid-generation queue; on turn commit the mailbox drains into the *next* turn as one attributed batch (`[Kira (Thorn)]: ...`). Generation latency IS the coalescing window — the GM answers the table, not each keystroke. +- Engine-initiated turns (module open, later: recaps) go through the same mutex. Exactly one code path calls the LLM. +- Backpressure v0: zod length cap on `chat.send` + one rule — if a room's pending inputs exceed ~10, drop with a notice. (Token-bucket tiers, kick, and the global LLM semaphore are cut; friends-by-room-code is the threat model. Seams noted in §10.) + +### 4.3 Turn pipeline + +``` +dequeue batch + → PRE: load room state; checkGate() + → ASSEMBLE: build message list (§4.4) + → LLM LOOP: stream provider response + ├─ text deltas → gm.streamDelta broadcasts + └─ tool calls → validate (zod) → execute in MEMORY → emit events + → append tool results → re-call provider + until stop | awaiting-player-roll | tool-round cap (8 → force text-only wrap-up) + → COMMIT (code, no LLM): + evaluateCompletion(scene.frontmatter, flags) // pure function + — auto-transition if exactly one legal option; module-complete flow on exit scenes + atomic-write dirty state files + conversation.json + transcript append (§6.3) + → broadcast; if mailbox non-empty → next turn +``` + +**Persistence timing (adjudicated):** tools mutate **in-memory** state during the turn; **nothing touches disk mid-turn**; all dirty files are atomically written at commit. Consequences, accepted and documented: a crash loses only the in-flight turn; disk is always at a clean turn boundary; cross-restart, an uncommitted turn is simply gone and the player retypes (v0 crash semantics — one sentence, no trailing-input re-queue heuristics). + +**Module-complete flow:** in M1 the archive write is code-only with a one-line summary (the LLM-written retrospective becomes a queued lifecycle turn in M2 — never an inline LLM call inside the commit stage). + +### 4.4 Context assembly (prompt-cache-first) + +Stability decreases monotonically down the message list; DeepSeek prefix caching needs no API changes, this ordering IS the optimization. Blocks 1–6 are one static system message, byte-identical across all rooms; 7–9 per-room; 10–11 per-turn tail. + +| # | Block | Source | Changes | +|---|---|---|---| +| 1 | Identity (~10 lines) | AGENTS.md:1-6 rewritten | never | +| 2 | Voice | SOUL.md (2 rewordings) | never | +| 3 | Rules core (~4-5K tok) | RULEBOOK.md minus State-Management/stale refs, merged with dnd-rules Tier-1 | never | +| 4 | Safety | safety-boundaries + response matrix, trimmed | never | +| 5 | Improv guidance | world-builder rules + improv tables, trimmed | never | +| 6 | Tool contract | written fresh (§8 — old TOOLS.md etiquette is an anti-pattern under transactional tools) | never | +| 7 | Table layer | table.md prefs + PC sheets **static parts only** (identity/stats/skills) | session/PC changes | +| 8 | Module layer | MODULE.md premise/GM-notes (optional sections tolerated) + rails.md + module-runner fragment | module init | +| 9 | Scene layer | active scene body + frontmatter-referenced NPC/location statics + npc-state deltas | scene transition | +| 10 | Volatile state | flags + completion condition + **current HP/conditions/slots/gold** + session meta | every turn | +| 11 | Conversation | window + queued batch | every turn | + +Mutable character fields live ONLY in block 10 (block 7 says "current HP/conditions are in the state block") — otherwise every damage tick would invalidate the cache for blocks 8–11. Honest worst-case non-conversation budget: **~16–18K tokens** for shadows-of-thornwall (measured against real file sizes, not the optimistic 12K); trim rails/derailment fragments at migration if this needs to shrink. + +**Two deletions, both deliberate:** +- *RULEBOOK conditional loading is dead* — rules are always in context. Model-requested reads reintroduce the "skipped it before a ruling" failure mode (worst on cheap models); appearing/disappearing mid-prompt blocks destroy the cache prefix. 4-5K tokens at cache-hit pricing rounds to zero. +- *The 11-intent router is dead* — conditional injection keys on booleans the engine already knows: active module? combat open (M2)? character-creation mode? lifecycle turn? No classifier, no `router.ts` (the file is `context.ts`; the intent concept must not reappear). + +### 4.5 Tools (M1: exactly four) + +Registered every turn; all validate with zod; all mutate in-memory state and emit protocol events; durability happens at commit. + +1. **`roll_dice { notation, mode?, reason, dc?, roller? }`** → server CSPRNG; returns full breakdown; emits `dice.rolled`. All GM rolls are public in v0 (hidden rolls = additive `visibility` param later, explicitly deferred). +2. **`request_player_roll { character_id, notation, reason, dc?, mode? }`** → emits `dice.requested`; **the turn ends here** (synthetic "waiting" tool result). Player click → server rolls → result enqueued as a mailbox entry → next turn resolves. **Group semantics defined now:** n calls per response are allowed; the turn ends with all n pending; the resolution turn starts only when all are fulfilled or a shared timeout auto-rolls — partial results do not wake the GM. **Fulfillment is compare-and-swap inside the room actor** (first of {click, other-tab click, timeout} wins; losers get `error{REQUEST_ALREADY_RESOLVED}`; clients clear the button on seeing `dice.rolled` with that requestId). Pending requests + their deadline are **persisted at commit** (`pending-rolls.json`) and re-armed on rehydration — a roll request must survive idle eviction and crash, else the table deadlocks. +3. **`update_character { character_id, hp_delta?, conditions_add/remove?, inventory_add/remove?, gold_delta?, reason }`** → clamped mutation of the live sheet; emits `character.snapshot` (full sheet). Live state is **checkpointed in `characters.json` at every commit where it changed** (adjudicated: "markdown at session end" alone would lose all mid-session HP/inventory on crash while the rehydrated conversation still remembers it). Markdown sheets remain the prose artifact, written at session end / module complete. +4. **`set_flag { name, value }`** → validates against the **module-wide flag union** (MODULE.md starting flags ∪ every `flag.*` token in ALL scene completion blocks). Adjudicated against per-scene validation: shadows-of-thornwall legitimately earns flags outside the scene that declares them (e.g. `knows_thornwall_secret` learnable in 05-temple-archive, declared in 10-sera-revelation); per-scene rejection would stall the endings. Unknown flag → error tool result, no write. Engine re-runs `evaluateCompletion` after execution. + +**Added in M2:** `advance_scene` (guards: current scene complete ∧ target ∈ `transitions.on_complete` ∧ **all target prereqs satisfied** ∧ no open combat — the prereq guard is mandatory, not optional: shadows' ending scenes carry `prereqs` that `on_complete` alone does not enforce, and a wrongly-entered ending can never complete; "legal next scenes" = on_complete ∩ prereqs-satisfied, used for both validation and the >1-option nudge), `read_memory` (mediated reads: npc/location/encounter/loot/rules_fragment/archive; rules fragments capped in code), `update_npc_state` (attitude enum standardized to `friendly|neutral|wary|hostile` — one zod definition shared by tool and npc-state writer), `note` (session journal), combat tools (`start_combat`/`apply_damage`/`next_turn`/`end_combat` + checkpointed combat FSM). **M3:** `generate_scene_art` — NOT registered before image gen exists (a visible disabled tool burns prompt tokens and invites cheap models to call a dead end). + +Cut permanently: generic `write_memory` (free-form file writes are the cheap-model footgun the typed tools exist to kill), tier/gate tools (model-visible billing is a security hole), `record_telemetry`. + +**Duplicate-tool guard:** within one turn, a second `roll_dice` with identical `{notation, roller, reason}` or a second `update_character` with identical args returns a tool error pointing at the prior result (cheap insurance against model-side retry duplicates). + +### 4.6 LLM provider (v0: one) + +```ts +interface ChatClient { + complete(req: { messages, tools }): AsyncIterable; +} +// ChatDelta: text | tool_call_partial | tool_calls_done | usage | done +``` + +One provider in v0: **DeepSeek** (`deepseek-chat` — not `deepseek-reasoner`, weak function calling) via the official `openai` SDK with `baseURL`; config from env with provider presets (`{deepseek, qwen, glm, kimi, custom}` name → baseURL/defaultModel map, so switching is an env edit). Streamed tool-call args accumulate per `tool_call.index`, execute on `finish_reason:"tool_calls"`. + +**Failure policy v0:** transient errors → retry same provider ×2 with backoff. Hard failure mid-stream → `gm.streamEnd{aborted:true}` + `system.notice` ("the GM stumbled — say that again"); committed tool effects stand (they're in the message list); the player resends. **No multi-provider failover, no capability-flag matrix, no mid-stream cross-provider resume, no jsonrepair until malformed args are actually observed** — that's operating knowledge to be earned on one provider first; the `ChatClient` seam is where it lands later. If a retry does resume a turn in-process: partial streamed text is appended as a completed assistant message and the retry emits under a **new** messageId. + +**Compaction v0 = naive truncation** (drop oldest messages past the threshold). Safe by construction: all hard state (flags, scene, HP, npc-state) is re-injected fresh every turn from engine stores — truncation can only lose color, never mechanics. The LLM summarizer (separate cheap non-streamed call, preservation list from the session-lifecycle reference) arrives with multi-session play in M2; only the at-threshold branch changes. + +### 4.7 Bootstrap flows in code + +BOOTSTRAP.md's state machine dies as prompt; its content survives as: +- **Room creation** (HTTP POST, §3.3): renders `table.md` from template with defaults. No interview, no compliance contract, no sentinel files (`room.json.phase` replaces them). +- **Module selection:** `GET /api/modules` from the boot-time ModuleRegistry → cards (title, level_range, length, tags, content warnings) with PC-pool compatibility; **host confirms** via `POST /api/rooms/:code/module` (host token required — one decider, no multiplayer deadlock; selection is HTTP like creation, so no picker events exist on the WS protocol); `provisionModuleInstance()` seeds per-table instance state; an engine-initiated turn narrates the entry scene's Setup; emits `module.changed{started}`. +- **Characters (M1): pregen picker ONLY.** 4–6 hand-written CharacterSheet objects committed under `content/` (sourced from the character-creator skill's quick-start pregens); a click binds one to your playerId and broadcasts `character.snapshot`. The conversational creation wizard (step FSM, SRD data as TS, draft persistence) is the single largest deferrable lift in the design and produces the same CharacterSheet later — M3, no rewrite risk. +- **Session lifecycle (M2):** `session.end` verb, recap turns, resume anchors, session logs. v0 has only `room.close`; mid-play eviction recovery = rehydrate + snapshot transcript tail, no recap prose. + +## 5. Rooms, Identity, Persistence + +### 5.1 Rooms + +- Room code = table id = directory name. 6 chars, Crockford-style alphabet (no 0/O/1/I/L/U), case-insensitive input, canonical uppercase, shareable as `/r/ABC234`. Collision: regenerate up to 5×; `mkdir` is the atomic arbiter. Codes never reused (ended tables keep their directory). +- Idle eviction: zero sockets ∧ no in-flight/queued turn ∧ 15 min idle → flush (safety net — commit already flushed), drop the actor. Rehydration on next join: `room.json` → `players.json` → memory layer → `conversation.json` → `pending-rolls.json` (re-arm timers) → `characters.json` over markdown sheets. Target < 200ms. +- `room.close` (host): commit, set `phase:"ended"`, broadcast, evict. Directory retained. + +### 5.2 Identity without accounts + +- Seat claim mints `playerId` + 192-bit token; `players.json` stores the **sha256 hash** (leaked data dir ≠ join credentials). Client stores `{playerId, token}` per room in localStorage. +- Reconnect = `room.join` with token → snapshot. Names are durable, case-insensitively unique against ALL bound players (returning "Bob" keeps his seat; a new Bob cannot impersonate him); conflict → `error{NAME_TAKEN}` + suggestion, client re-prompts. +- Two tabs: allowed; presence is a socket refcount per player; the dice CAS rule makes double-clicking from two tabs harmless. +- Lost token: rejoin under a new name; client surfaces a copyable rejoin link (`/r/CODE?t=token`) as zero-cost mitigation; the real fix is accounts (seam: `authenticateJoin()` is the single verification point). +- Host = `room.json.hostPlayerId`. Host-only: module-pick confirm, `room.close` (M2 adds `session.end`; kick is deferred — `players.json` reserves an optional `kicked` field so it's additive). +- **Safety is never host-gated:** any player's safety flag (M2 surface) force-injects the safety fragment and is logged. Gating safety behind one person is a liability anti-pattern. + +### 5.3 On-disk layout + +``` +data/tables/ABC234/ + room.json # { code, phase, hostPlayerId, createdAt, lastActiveAt, schemaVersion } + players.json # [{ playerId, name, role, tokenHash, createdAt, lastSeenAt }] + conversation.json # LLM message window + snapshot transcript tail, written at commit + characters.json # live mechanical state checkpoint (when dirty) + pending-rolls.json # unanswered dice.requested + deadlines (when any) + transcript.jsonl # ONE flat append-only record (turn events + player chat). + # Record only — never read on the hot path, never client-replayed. + memory/ + table.md # prose; template-rendered at creation + characters/.md # prose sheets, written session-end/module-complete + archive/-.md + modules// + flags.json npc-state.md combat.json (M2, while open) session-log/ (M2) +``` + +**md vs json rule:** what the LLM reads as prose or a human might edit = markdown; what only code reads/writes = zod-schema'd JSON. This kills the two cheap-model-hostile patterns (markdown read-modify-write surgery; model-maintained counters). Vestigial surfaces (`memory/npcs/`, `memory/locations/`, `user-tier.md`, `analytics.md`, `scene-cache.md`, sentinels) do not exist here. + +**Write strategy:** unit of durability = turn commit. `writeFileAtomic` (write tmp → rename) for every dirty file; transcript append; then broadcast. Player chat appends immediately (it isn't game state). fsync deliberately omitted (atomic rename covers process crash; OS-level power loss eating seconds of page cache is acceptable for a game transcript — the upgrade is one flag later). `seq` high-watermark is persisted in `room.json` at commit; uncommitted seqs are burned on crash, and snapshot-reset reconnect re-anchors every client regardless. + +**Analytics v0:** provider usage numbers logged via pino per call (they arrive free in the API response). Structured per-table analytics files arrive with the subscription gate; the hook point (turn commit) is already obvious. + +### 5.4 Module content at runtime + +`content/modules/` ships with the server, read-only. Boot-time **ModuleRegistry**: zod-parse all MODULE.md + scene frontmatter (tolerant of unknown keys), validate the graph (entry/exit/transition/**prereq** ids resolve, completion flags declared, encounter refs exist), keep all bodies as opaque strings. Startup fails loudly on invalid content — this absorbs `validate-skills.sh`'s module checks into the load path (and into `pnpm test`, §7). `provisionModuleInstance` seeds `flags.json` from `starting_flags`, empty `npc-state.md`, `active-module` pointer with pinned `moduleVersion` (mismatch mid-campaign: warn and continue). + +## 6. Content Migration — `scripts/migrate-modules.ts` (one-time, output committed) + +Copy `workspace/modules/{tutorial,shadows-of-thornwall}` → `content/modules/`, prompts → `content/prompts/`, SRD → `content/srd/`, applying: + +1. **MODULE.md:** lift `## Initialization` starting flags → frontmatter `starting_flags` (`boolean | string`; falsy = `false | "none"`). +2. **Scenes:** replace `on_derail: rails.md#` with scene-id/wildcard keys matching rails.md rows; lift `## Key NPCs / Locations` relative refs → frontmatter `references: {npcs, locations, encounters}`; define the prereq grammar (`.completed`) in the zod schema. +3. **SOUL.md:** two sentence-level rewordings (not line deletions): "defined in their memory file" → "defined in the NPC state provided in your context"; "Read the NPC's memory file before every significant interaction. Stay consistent." → "Current NPC state is provided in your context; stay consistent with it." +4. **rulebook.md:** RULEBOOK.md minus §State Management / ROUTING pointer / stale `campaign.md` refs, merged with dnd-rules Tier-1 snapshot (dedupe), dice/combat passages rewritten around tools ("never state a result you didn't get from a tool"). +5. **Tool contract:** written FRESH. Old TOOLS.md etiquette ("don't interrupt an intense scene to update files") is an anti-pattern under transactional tools — the new invariant is *call the mutating tool the moment the event happens in the fiction*. Keep only the still-valid bullets (never block narration on image generation). +6. **Session/recap fragments (M2):** rewrite, don't lift — the session-lifecycle reference is pre-module-era (campaign.md/quests.md refs); source from the SKILL.md workflow + Must-Include/Must-Preserve lists; the session-log format is defined in code (no template file exists). +7. **SRD:** split the four spell files (24–32KB each!) per-heading; recompile the keyword index to section granularity as `index.json`; resolver caps returned bytes. (Unsplit, one fireball lookup injects ~8K tokens.) +8. **Untouched:** NPC/location/encounter bodies, loot.md, rails.md prose, scene bodies, premises — verbatim prompt fragments. + +Acceptance: `pnpm validate:content` passes; legacy `validate-skills.sh` still passes against untouched `workspace/`. + +## 7. Testing Strategy + +Principle: test the deterministic spine hard, fake the LLM always, skip pixels. + +- **protocol:** schema round-trip + rejection tests; exhaustiveness (every union member has a fixture). +- **FakeLLM** implements the real streaming `ChatClient` interface (scripted `ChatDelta` sequences, friendly `{text, toolCalls}` builder; throws when the script is exhausted — catches loop bugs). Required scripted cases: text-then-tool-call interleaved in one response; mid-iteration throw (failure path). Two assertion surfaces: what the engine *sends* (context assembly: combat fragment present iff combat open; scene block swaps after transition; mutable HP in volatile block only) and what it *does* (tool dispatch, guards, events, commit writes). +- **Pure functions:** dice parser/roller (seeded RNG), `evaluateCompletion` (any_of/all_of, falsy `"none"`), pregen binding, truncation trigger. +- **Invariant guards (former prompt rules, now tests):** transition mid-combat rejected; `set_flag` outside the module union rejected; transition target outside on_complete∩prereqs rejected; gated user → zero LLM calls; dice CAS (double answer → `REQUEST_ALREADY_RESOLVED`); duplicate-tool guard. +- **Persistence (tmp dirs):** provision/rehydrate round-trip; reconnect resumes the seat; crash simulation (write → new engine over same dir → resume context matches); **commit a turn ending in `request_player_roll`, rebuild from disk, assert the snapshot still carries the request and `dice.roll{requestId}` resolves**. +- **Content as tests:** all of `content/modules/` through the real schemas + cross-ref/prereq/flag resolution (replaces validate-skills.sh in CI). +- **One e2e smoke:** real Fastify on an ephemeral port, FakeLLM, tutorial module; two real `ws` clients; one turn; assert both receive identical ordered events and the flag write hit the tmp dir. +- **Not tested:** UI pixels (eyeball in dev), real-model output quality (manual prompt evals, never CI), load/perf, image providers. + +## 8. Milestones + +| | Scope | Done means | +|---|---|---| +| **M0** | Scaffold: monorepo, protocol package, Fastify+WS, POST /api/rooms, join/snapshot/presence, chat echo. No LLM. | Two browsers in one room see each other's messages; reconnect restores state. | +| **M1** | Playable: engine turn loop, 4 tools, DeepSeek, ModuleRegistry + tutorial, pregen picker, dice request/roll buttons + breakdown display, commit persistence, truncation. | **Friends join by room code and finish the tutorial module.** | +| **M2** | Campaign: advance_scene + prereq guard, combat FSM + combat tools + `combat.*` schemas, session lifecycle (session.end, recap turns, resume anchors, session logs), LLM compaction, read_memory + SRD lookups, update_npc_state, note, character-sheet panel UI. | shadows-of-thornwall across multiple sessions. | +| **M3** | Experience: scene-art pipeline (`media.*` schemas, generate_scene_art, cache/quota), conversational character creation, dice animation polish, table-prefs form. | | +| **M4+** | Productization: accounts, subscription gate (real), independent moderation layer, battle map (`map.*`), multi-provider failover. | | + +Everything cut from M1 re-enters additively through seams named in this document; nothing requires a rewrite. + +## 9. Adjudicated Decisions (do not relitigate during implementation) + +1. **Protocol package is canonical for the wire.** Event names in §3.3 are final; earlier draft vocabularies (`narration.*`, `chat.player`, `player.presence`, `scene.transitioned`, `setup.*`, etc.) are dead. +2. **Recovery is snapshot-only.** No client-facing event replay; `transcript.jsonl` is a record. Join is an atomic actor operation. +3. **Persistence is commit-boundary.** No mid-turn disk writes; `flags.json` not `flags.md`; conversation/characters/pending-rolls checkpointed at commit. +4. **Room creation is HTTP POST; v0 has no table-prefs interview.** +5. **`set_flag` validates the module-wide union**, not per-scene. +6. **`advance_scene` (M2) enforces target prereqs**, not just `on_complete` membership. +7. **One provider (DeepSeek) in v0**; `ChatClient` is the seam; FakeLLM implements the streaming interface. +8. **Compaction v0 is truncation**; mechanics are commit-store-truth re-injected every turn, never window-truth. +9. **Pregens are the only M1 character path.** +10. **No `character.patch`** — full `character.snapshot` on every change. +11. **Reserved namespaces stay unschema'd** until their milestone (`media.*`, `combat.*`, `session.*`, `map.*`). +12. **`generate_scene_art` is not registered until image gen exists.** +13. **Leave does not sever the token↔seat binding.** +14. **Intent routing does not exist**; context assembly keys on engine-state booleans. + +## 10. Deferred Features → Seams + +| Deferred | Seam (enforce now, build later) | +|---|---| +| Accounts | `authenticateJoin()` single verification point; `playerId` already the universal key | +| Payments / gate | `checkGate()` stub already called before every LLM turn → fail-closed by construction on day one of implementation; usage from real API counters | +| Moderation (existential risk: companion-chatbot liability) | All player text enters via one function, all GM text exits via one function; `moderateInput/moderateOutput` hooks defined, no-op in v0; transcript.jsonl is the audit trail | +| Multi-process scaling | Invariants: a room actor touches only its own subtree; only cross-room shared state is the read-only ModuleRegistry (+ future LLM semaphore) | +| Kick / host transfer | `players.json` reserves `kicked`; `hostPlayerId` is one mutable field | +| Rate limiting beyond the pending cap | Token bucket slots into the single message-ingress function | +| LLM concurrency semaphore | Wrap the one `ChatClient.complete` await site | +| Export / deletion (GDPR-ish) | Per-table directory = `tar` / `rm -rf` | +| Native desktop client | Tauri wrap of the same web build; protocol is client-agnostic by construction | From 16817e03102c211e907dd38127bc671364c5d6e2 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Tue, 16 Jun 2026 14:53:25 +0800 Subject: [PATCH 02/15] Amend harness spec with OpenClaw-runtime gap analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-agent gap analysis inventoried every runtime service OpenClaw (and macOS launchd beneath it) provided the GM deployment, then cross-checked the spec for silently-borrowed plumbing. Headline: the GM was never actually played over Discord — all live testing ran through OpenClaw's gateway/control surface (sessions.reset used 13x in 3 days), which had no successor in the spec. Player-facing transport was already well covered. The blind spot was the operator/process boundary. Eight amendments, all M0-M1, none touching the adjudicated architecture: - §2: systemd supervision, journald log rotation, SIGTERM drain, /healthz - §3.3: spend gate on POST /api/rooms (invite code + per-IP cap); gm.stop reserved - §4.4: block-10 session meta defined to inject clock + idle-elapsed - §4.6: per-turn wall-clock timeout + max_tokens (wedged-stream safety net) - §5.2: WS ping/pong dead-socket detection (presence + eviction both key on it) - §5.5 (new): operator surface — admin inspect/evict routes, per-turn LLM debug capture, conversation-reset verb - §8: M0/M1 acceptance criteria updated - §10: rate-limit seam covers both ingress surfaces; away-player + gm.stop seams Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-12-online-harness-design.md | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-online-harness-design.md b/docs/superpowers/specs/2026-06-12-online-harness-design.md index 7590740..b3c2643 100644 --- a/docs/superpowers/specs/2026-06-12-online-harness-design.md +++ b/docs/superpowers/specs/2026-06-12-online-harness-design.md @@ -1,7 +1,7 @@ # Online Harness Design — Self-Built TypeScript Server + Web Client **Date:** 2026-06-12 -**Status:** Approved (design review with owner; multi-agent adversarial review applied) +**Status:** Approved (design review with owner; multi-agent adversarial review applied). **Amended 2026-06-12** with an OpenClaw-runtime gap analysis (§2 ops, §3.3 spend gate + `gm.stop`, §4.4 clock, §4.6 turn timeout, §5.2 dead-socket, §5.5 operator surface, §8 milestone acceptance) — closing the operator/process-boundary plumbing the content silently borrowed from OpenClaw + launchd. **Supersedes:** the OpenClaw + Discord runtime for all new development. `workspace/` stays frozen and functional until cutover. ## 1. Context & Goals @@ -60,6 +60,12 @@ game-master/ **Deployment:** one Node process serves API + WS + built SPA. Small VPS; `DATA_DIR` on persistent disk; backup = cron `tar` (flat files by design). Dev = two processes (`tsx watch` + Vite HMR with `/ws` + `/api` proxy). +**Process supervision & ops (gap-analysis amendment — OpenClaw never owned this either; macOS launchd did).** The spec designs crash *recovery* (§5.1 rehydration, §7 crash tests) but must also own *resurrection*: +- Run under **systemd** `Restart=on-failure` (or pm2). The process must come back without owner intervention — a crash at hour 2 of a session otherwise ends the night. +- **Logs to stdout via pino → journald** owns rotation/retention. `transcript.jsonl` per-table append is accepted as text-scale growth, folded into the existing `tar` backup. (OpenClaw's own 16.8 MB `gateway.err.log` / 234 MB `probe.log` are the cautionary tale of unrotated logs.) +- **`SIGTERM` handler = graceful drain:** stop accepting new WS messages, let each in-flight turn reach commit (or abort it per §4.6), then exit; clients self-heal by snapshot rejoin (§3.2). Makes deploys safe mid-session. +- **`GET /healthz`** → `200` with `{ uptime, liveRooms }` for an uptime monitor. + ## 3. Event Protocol (`packages/protocol`) The protocol package is the **single source of truth for everything on the wire**. Server and client import the same zod schemas. Where earlier drafts of subsystem designs named events differently, the names below are canonical. @@ -120,6 +126,10 @@ Server → client: **Room creation is HTTP, not WS** (decision): `POST /api/rooms { hostName, roomName? }` → `{ roomCode, playerId, token }` → client opens the socket and joins. This deletes the pre-join `room: null` special case and the `room.create/created` events. v0 collects **no table prefs** — `table.md` is rendered from the template with defaults (mixed tone, standard lethality); a prefs form is a later additive HTTP feature. No setup-wizard events exist. +**Spend-surface gate on room creation (gap-analysis amendment).** `POST /api/rooms` is the only unauthenticated surface that causes model spend (a room mints a host seat and unlocks IC chat → LLM calls). OpenClaw gated this with Discord guild allowlists + device pairing; the harness must not leave it wide open. v0 (M1): the route honors an optional `CREATE_INVITE_CODE` env var (absent = open, fine for an unlisted URL among friends) **and** a per-IP daily creation cap (in-memory counter). This extends the §10 rate-limit seam to name *both* ingress surfaces — WS messages and room-creation HTTP — not just chat. + +**Host-only `gm.stop` is a reserved later-additive event** (ignore-unknown-types makes it free): a human request to abort an in-flight stream. The wall-clock turn timeout (§4.6) is the v0 safety net; `gm.stop` is the M1+ explicit control. + **Reserved namespaces, deliberately unschema'd:** `map.*`, `media.*`, `combat.*`, `session.*`. Reserving means: the names are documented as taken, and the ignore-unknown-types rule makes their later addition non-breaking. Writing their schemas now would be guessing (adjudicated: the same logic the original design correctly applied to `map.*` applies to media/combat too; `room.snapshot` gains optional fields for them later for free via zod strip). **Not in the protocol and why:** accounts/auth (token field is the seam), billing ops (gate results surface as `error{GATE_LIMIT}`/`system.notice{UPSELL}` only; gate runs server-side before any LLM call — never model- or client-influenced), raw LLM traffic (providers stay swappable; prompts stay private), analytics, voice, file upload, message edit/delete/reactions/typing, history pagination (snapshot tail + markdown memory are the record; infinite scrollback would be an HTTP GET later). @@ -185,6 +195,8 @@ Stability decreases monotonically down the message list; DeepSeek prefix caching | 10 | Volatile state | flags + completion condition + **current HP/conditions/slots/gold** + session meta | every turn | | 11 | Conversation | window + queued batch | every turn | +**Block 10 "session meta" is defined (gap-analysis amendment — the model must not be clockless):** server ISO datetime + minutes since the last player message + session number. OpenClaw's system prompt carried a Time section the content silently leaned on (date-stamped writes, HEARTBEAT's idle "where we left off"). Costs nothing in M1 (block 10 is already the per-turn cache-safe tail) and becomes load-bearing for M2 recap/resume turns — "it's been three days since the party left Thornwall" can only be honest if the model can see the clock. + Mutable character fields live ONLY in block 10 (block 7 says "current HP/conditions are in the state block") — otherwise every damage tick would invalidate the cache for blocks 8–11. Honest worst-case non-conversation budget: **~16–18K tokens** for shadows-of-thornwall (measured against real file sizes, not the optimistic 12K); trim rails/derailment fragments at migration if this needs to shrink. **Two deletions, both deliberate:** @@ -219,6 +231,8 @@ One provider in v0: **DeepSeek** (`deepseek-chat` — not `deepseek-reasoner`, w **Failure policy v0:** transient errors → retry same provider ×2 with backoff. Hard failure mid-stream → `gm.streamEnd{aborted:true}` + `system.notice` ("the GM stumbled — say that again"); committed tool effects stand (they're in the message list); the player resends. **No multi-provider failover, no capability-flag matrix, no mid-stream cross-provider resume, no jsonrepair until malformed args are actually observed** — that's operating knowledge to be earned on one provider first; the `ChatClient` seam is where it lands later. If a retry does resume a turn in-process: partial streamed text is appended as a completed assistant message and the retry emits under a **new** messageId. +**Wall-clock turn bound + output cap (gap-analysis amendment — a wedged stream holds the per-room mutex and freezes the whole table at `gm.status:thinking`).** A per-turn wall clock (default 120s, env-tunable) wraps the `ChatClient.complete` iteration via an `AbortController` at that single await site (the same spot §10 reserves for the LLM semaphore and §3.3's `gm.stop` will wire into); on expiry, treat as hard failure → the existing `aborted` streamEnd path, mutex released. The provider preset carries `max_tokens` so a rambling narration can't burn the owner's key unbounded. OpenClaw shipped `/stop` + `chat.abort` + `abortedLastRun`; this is its v0 successor. + **Compaction v0 = naive truncation** (drop oldest messages past the threshold). Safe by construction: all hard state (flags, scene, HP, npc-state) is re-injected fresh every turn from engine stores — truncation can only lose color, never mechanics. The LLM summarizer (separate cheap non-streamed call, preservation list from the session-lifecycle reference) arrives with multi-session play in M2; only the at-threshold branch changes. ### 4.7 Bootstrap flows in code @@ -242,6 +256,7 @@ BOOTSTRAP.md's state machine dies as prompt; its content survives as: - Seat claim mints `playerId` + 192-bit token; `players.json` stores the **sha256 hash** (leaked data dir ≠ join credentials). Client stores `{playerId, token}` per room in localStorage. - Reconnect = `room.join` with token → snapshot. Names are durable, case-insensitively unique against ALL bound players (returning "Bob" keeps his seat; a new Bob cannot impersonate him); conflict → `error{NAME_TAKEN}` + suggestion, client re-prompts. - Two tabs: allowed; presence is a socket refcount per player; the dice CAS rule makes double-clicking from two tabs harmless. +- **Dead-socket detection (gap-analysis amendment — both presence AND idle eviction key on socket liveness, M0 plumbing).** A half-open TCP connection (laptop lid, mobile drop) sends no WS close event, so without active probing the refcount never decrements: ghost "online" players in `presence.update`, and room actors whose refcount never reaches zero leak (with their timers) for the process lifetime. The server sends a **WS protocol-level ping every 30s per socket**; two missed pongs → `socket.terminate()` → normal close path decrements the refcount, fires `presence.update`, re-arms eviction eligibility. These are ws *frames*, not protocol events — no schema change. (Discord/OpenClaw owned this invisibly via gateway heartbeating.) Test alongside §7 persistence: a terminated socket with no close handshake reaches the zero-socket state. - Lost token: rejoin under a new name; client surfaces a copyable rejoin link (`/r/CODE?t=token`) as zero-cost mitigation; the real fix is accounts (seam: `authenticateJoin()` is the single verification point). - Host = `room.json.hostPlayerId`. Host-only: module-pick confirm, `room.close` (M2 adds `session.end`; kick is deferred — `players.json` reserves an optional `kicked` field so it's additive). - **Safety is never host-gated:** any player's safety flag (M2 surface) force-injects the safety fragment and is logged. Gating safety behind one person is a liability anti-pattern. @@ -255,8 +270,10 @@ data/tables/ABC234/ conversation.json # LLM message window + snapshot transcript tail, written at commit characters.json # live mechanical state checkpoint (when dirty) pending-rolls.json # unanswered dice.requested + deadlines (when any) - transcript.jsonl # ONE flat append-only record (turn events + player chat). - # Record only — never read on the hot path, never client-replayed. + transcript.jsonl # ONE flat append-only record (turn events + player chat + per-turn + # debug record, §5.5). Record only — never read on the hot path, + # never client-replayed. + debug/ # GM_DEBUG=1 only: full provider request/response bodies per turn (§5.5) memory/ table.md # prose; template-rendered at creation characters/.md # prose sheets, written session-end/module-complete @@ -275,6 +292,14 @@ data/tables/ABC234/ `content/modules/` ships with the server, read-only. Boot-time **ModuleRegistry**: zod-parse all MODULE.md + scene frontmatter (tolerant of unknown keys), validate the graph (entry/exit/transition/**prereq** ids resolve, completion flags declared, encounter refs exist), keep all bodies as opaque strings. Startup fails loudly on invalid content — this absorbs `validate-skills.sh`'s module checks into the load path (and into `pnpm test`, §7). `provisionModuleInstance` seeds `flags.json` from `starting_flags`, empty `npc-state.md`, `active-module` pointer with pinned `moduleVersion` (mismatch mid-campaign: warn and continue). +### 5.5 Operator surface (gap-analysis amendment — the most-used affordance in live testing had no successor) + +The gap analysis surfaced the design's one systematic blind spot: it rebuilt everything Discord did *for players* but nothing OpenClaw's gateway/control-UI did *for the owner*. Crucially, the GM was **never actually played over Discord** — all live testing ran through that operator surface, and `sessions.reset` (clear the conversation) was used 13 times in 3 days. None of it had a successor in the spec, and ssh-and-edit-JSON is actively unsafe because a hot room actor clobbers hand-edits at the next commit. Three owner affordances, all M1, all owner-token-gated (`ADMIN_TOKEN` in `.env.example`), all reusing existing machinery: + +1. **Inspection routes (HTTP, `ADMIN_TOKEN`):** `GET /api/admin/rooms` (registry: code, phase, lastActiveAt, liveness); `GET /api/admin/rooms/:code/state` (the same serializer `room.snapshot` already uses); `POST /api/admin/rooms/:code/evict` (the existing flush+drop path — this is what makes editing `data/tables//*.json` safe: evict first, then edit, then the next join rehydrates). +2. **Per-turn LLM capture (debugging a bad GM reply — weekly necessity on a cheap model).** `transcript.jsonl` carries a **defined turn record**: `{ turnId, ts, blockSizesOrHashes, windowLen, toolCalls, toolResults, finalText, usage, finishReason, retryCount }` — i.e. *what turn N actually saw and did*, not just the post-truncation window the next turn will see. `GM_DEBUG=1` additionally dumps full provider request/response bodies to `data/tables//debug/`. This is the successor to OpenClaw's JSONL transcript + `systemPromptReport`, which *was* the owner's entire debugging loop. +3. **Conversation-reset verb (recover a poisoned/derailed context window).** Host-only c2s event **or** admin POST: truncate `conversation.json` at a turn boundary, broadcast `system.notice`, optionally enqueue one engine-initiated re-orientation turn through the existing mutex. Adjudicated decision 8 (mechanics are store-truth, the window is only color) makes this nearly free and totally safe — flags/scene/characters survive untouched. Without it, M1's first bad-model spiral has no recovery short of abandoning the room. + ## 6. Content Migration — `scripts/migrate-modules.ts` (one-time, output committed) Copy `workspace/modules/{tutorial,shadows-of-thornwall}` → `content/modules/`, prompts → `content/prompts/`, SRD → `content/srd/`, applying: @@ -307,9 +332,9 @@ Principle: test the deterministic spine hard, fake the LLM always, skip pixels. | | Scope | Done means | |---|---|---| -| **M0** | Scaffold: monorepo, protocol package, Fastify+WS, POST /api/rooms, join/snapshot/presence, chat echo. No LLM. | Two browsers in one room see each other's messages; reconnect restores state. | -| **M1** | Playable: engine turn loop, 4 tools, DeepSeek, ModuleRegistry + tutorial, pregen picker, dice request/roll buttons + breakdown display, commit persistence, truncation. | **Friends join by room code and finish the tutorial module.** | -| **M2** | Campaign: advance_scene + prereq guard, combat FSM + combat tools + `combat.*` schemas, session lifecycle (session.end, recap turns, resume anchors, session logs), LLM compaction, read_memory + SRD lookups, update_npc_state, note, character-sheet panel UI. | shadows-of-thornwall across multiple sessions. | +| **M0** | Scaffold: monorepo, protocol package, Fastify+WS, POST /api/rooms, join/snapshot/presence, chat echo, **WS ping/pong dead-socket detection (§5.2)**. No LLM. | Two browsers in one room see each other's messages; reconnect restores state; **a force-killed socket leaves no ghost player**. | +| **M1** | Playable: engine turn loop, 4 tools, DeepSeek, ModuleRegistry + tutorial, pregen picker, dice request/roll buttons + breakdown display, commit persistence, truncation. **Ops: systemd + `/healthz` + SIGTERM drain (§2); per-turn wall-clock + max_tokens (§4.6); admin inspection/evict routes, per-turn debug capture, conversation-reset verb (§5.5); room-creation invite-code + per-IP cap (§3.3).** | **Friends join by room code and finish the tutorial module; `kill -9` mid-session and the table resumes without owner intervention; the owner can dump a room's state and reset a derailed conversation.** | +| **M2** | Campaign: advance_scene + prereq guard, combat FSM + combat tools + `combat.*` schemas, session lifecycle (session.end, recap turns, resume anchors, session logs), LLM compaction, read_memory + SRD lookups, update_npc_state, note, character-sheet panel UI, **away-player notifications (title badge + browser Notification API on `gm.streamEnd`/`dice.requested` while tab hidden — client-only)**. | shadows-of-thornwall across multiple sessions. | | **M3** | Experience: scene-art pipeline (`media.*` schemas, generate_scene_art, cache/quota), conversational character creation, dice animation polish, table-prefs form. | | | **M4+** | Productization: accounts, subscription gate (real), independent moderation layer, battle map (`map.*`), multi-provider failover. | | @@ -341,7 +366,9 @@ Everything cut from M1 re-enters additively through seams named in this document | Moderation (existential risk: companion-chatbot liability) | All player text enters via one function, all GM text exits via one function; `moderateInput/moderateOutput` hooks defined, no-op in v0; transcript.jsonl is the audit trail | | Multi-process scaling | Invariants: a room actor touches only its own subtree; only cross-room shared state is the read-only ModuleRegistry (+ future LLM semaphore) | | Kick / host transfer | `players.json` reserves `kicked`; `hostPlayerId` is one mutable field | -| Rate limiting beyond the pending cap | Token bucket slots into the single message-ingress function | -| LLM concurrency semaphore | Wrap the one `ChatClient.complete` await site | +| Rate limiting beyond the pending cap | Token bucket slots into the single message-ingress function — applies to **both** WS chat and `POST /api/rooms` (§3.3) | +| LLM concurrency semaphore | Wrap the one `ChatClient.complete` await site (same site as the §4.6 turn timeout and `gm.stop`) | | Export / deletion (GDPR-ish) | Per-table directory = `tar` / `rm -rf` | | Native desktop client | Tauri wrap of the same web build; protocol is client-agnostic by construction | +| Push/email re-engagement (away players) | M2 ships in-tab Notification API; out-of-app push/email is an explicit M4 cut (the §5.5/notification seam is the client-side hook) | +| `gm.stop` (human abort) | Reserved additive event (§3.3); wires into the same abort site as the §4.6 wall-clock timeout | From fde55c2b466cc6e61562e4503b4e76ddb70572d2 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:40:55 +0800 Subject: [PATCH 03/15] chore: scaffold pnpm/typescript monorepo for online harness --- .env.example | 7 ++ .gitignore | 4 + package.json | 16 ++++ pnpm-lock.yaml | 216 ++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + tsconfig.base.json | 15 +++ 6 files changed, 260 insertions(+) create mode 100644 .env.example create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..087df65 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Server process config (zod-validated at boot; see packages/server/src/config.ts) +PORT=8787 +DATA_DIR=./data +WS_PING_MS=30000 +WS_MAX_MISSED_PONGS=2 +IDLE_EVICT_MS=900000 +ROOM_MAX_PLAYERS=6 diff --git a/.gitignore b/.gitignore index 1ee0a42..819c731 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ artifacts/openclaw-tests/ __pycache__/ .DS_Store eval-results/ +node_modules/ +data/ +dist/ +*.tsbuildinfo diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd9e4c8 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "game-master-harness", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "build": "pnpm -r build", + "dev": "concurrently -n server,web -c blue,green \"pnpm --filter @gm/server dev\" \"pnpm --filter @gm/web dev\"" + }, + "devDependencies": { + "concurrently": "^9.1.0", + "typescript": "^5.6.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..1cfab89 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,216 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + concurrently: + specifier: ^9.1.0 + version: 9.2.3 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + +packages: + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.3: + resolution: {integrity: sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==} + engines: {node: '>=18'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.3: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.4 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + require-directory@2.1.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + shell-quote@1.8.4: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..d84c1cb --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": [] + } +} From ee2c3a4223ae431fdd59e55ed9d0cb6770da5e65 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:42:45 +0800 Subject: [PATCH 04/15] feat(protocol): envelope schema + makeEnvelope/parseEnvelope --- packages/protocol/package.json | 18 + packages/protocol/src/envelope.ts | 49 ++ packages/protocol/src/index.ts | 1 + packages/protocol/test/envelope.test.ts | 40 ++ packages/protocol/tsconfig.json | 7 + packages/protocol/vitest.config.ts | 5 + pnpm-lock.yaml | 885 ++++++++++++++++++++++++ 7 files changed, 1005 insertions(+) create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/envelope.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/test/envelope.test.ts create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/protocol/vitest.config.ts diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..a1529ea --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,18 @@ +{ + "name": "@gm/protocol", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "vitest": "^2.1.0" + } +} diff --git a/packages/protocol/src/envelope.ts b/packages/protocol/src/envelope.ts new file mode 100644 index 0000000..5717e33 --- /dev/null +++ b/packages/protocol/src/envelope.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export const PROTOCOL_VERSION = 1 as const; + +export const Envelope = z.object({ + v: z.literal(1), + id: z.string().min(1), + type: z.string().min(1), + room: z.string().nullable(), + seq: z.number().int().positive().optional(), + ts: z.number().int(), + payload: z.unknown(), +}); + +export type Envelope = z.infer; + +export function makeEnvelope(input: { + type: string; + room: string | null; + payload: unknown; + id?: string; + seq?: number; + ts?: number; +}): Envelope { + const env: Envelope = { + v: 1, + id: input.id ?? crypto.randomUUID(), + type: input.type, + room: input.room, + ts: input.ts ?? Date.now(), + payload: input.payload, + }; + if (input.seq !== undefined) env.seq = input.seq; + return env; +} + +export function parseEnvelope( + raw: string, +): { ok: true; envelope: Envelope } | { ok: false; error: string } { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return { ok: false, error: "invalid JSON" }; + } + const result = Envelope.safeParse(json); + if (!result.success) return { ok: false, error: result.error.message }; + return { ok: true, envelope: result.data }; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..8a749ff --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1 @@ +export * from "./envelope.js"; diff --git a/packages/protocol/test/envelope.test.ts b/packages/protocol/test/envelope.test.ts new file mode 100644 index 0000000..a7d22ef --- /dev/null +++ b/packages/protocol/test/envelope.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { Envelope, makeEnvelope, parseEnvelope, PROTOCOL_VERSION } from "../src/index.js"; + +describe("envelope", () => { + it("makeEnvelope fills defaults and validates against the schema", () => { + const env = makeEnvelope({ type: "chat.message", room: "ABC234", payload: { text: "hi" } }); + expect(env.v).toBe(1); + expect(typeof env.id).toBe("string"); + expect(env.id.length).toBeGreaterThan(0); + expect(typeof env.ts).toBe("number"); + expect(env.seq).toBeUndefined(); + expect(() => Envelope.parse(env)).not.toThrow(); + }); + + it("makeEnvelope includes seq only when provided", () => { + expect(makeEnvelope({ type: "x", room: "R", payload: {} }).seq).toBeUndefined(); + expect(makeEnvelope({ type: "x", room: "R", payload: {}, seq: 7 }).seq).toBe(7); + }); + + it("parseEnvelope accepts a valid wire string", () => { + const wire = JSON.stringify(makeEnvelope({ type: "room.join", room: null, payload: {} })); + const res = parseEnvelope(wire); + expect(res.ok).toBe(true); + if (res.ok) expect(res.envelope.type).toBe("room.join"); + }); + + it("parseEnvelope rejects malformed JSON", () => { + const res = parseEnvelope("{not json"); + expect(res.ok).toBe(false); + }); + + it("parseEnvelope rejects a wrong protocol version", () => { + const bad = JSON.stringify({ ...makeEnvelope({ type: "x", room: null, payload: {} }), v: 2 }); + expect(parseEnvelope(bad).ok).toBe(false); + }); + + it("PROTOCOL_VERSION is 1", () => { + expect(PROTOCOL_VERSION).toBe(1); + }); +}); diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000..82f8fdb --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM"] + }, + "include": ["src", "test"] +} diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts new file mode 100644 index 0000000..1bd203e --- /dev/null +++ b/packages/protocol/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { environment: "node" }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cfab89..685e90b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,329 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/protocol: + dependencies: + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + vitest: + specifier: ^2.1.0 + version: 2.1.9 + packages: + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -25,10 +346,26 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -45,13 +382,46 @@ packages: engines: {node: '>=18'} hasBin: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -64,10 +434,43 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -75,6 +478,19 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -91,6 +507,24 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -103,6 +537,72 @@ packages: engines: {node: '>=14.17'} hasBin: true + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -119,19 +619,224 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/estree@1.0.9': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -153,24 +858,128 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + emoji-regex@8.0.0: {} + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fsevents@2.3.3: + optional: true + get-caller-file@2.0.5: {} has-flag@4.0.0: {} is-fullwidth-code-point@3.0.0: {} + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + require-directory@2.1.1: {} + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + rxjs@7.8.2: dependencies: tslib: 2.8.1 shell-quote@1.8.4: {} + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -189,12 +998,86 @@ snapshots: dependencies: has-flag: 4.0.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + tree-kill@1.2.2: {} tslib@2.8.1: {} typescript@5.9.3: {} + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + vitest@2.1.9: + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -214,3 +1097,5 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + + zod@4.4.3: {} From b205103687e0a0d333e7b491d73fa3f29ceb4364 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:44:02 +0800 Subject: [PATCH 05/15] feat(protocol): M0 event catalogue, payload schemas, error codes --- packages/protocol/src/codes.ts | 11 +++ packages/protocol/src/events.ts | 108 ++++++++++++++++++++++++++ packages/protocol/src/index.ts | 2 + packages/protocol/test/events.test.ts | 62 +++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 packages/protocol/src/codes.ts create mode 100644 packages/protocol/src/events.ts create mode 100644 packages/protocol/test/events.test.ts diff --git a/packages/protocol/src/codes.ts b/packages/protocol/src/codes.ts new file mode 100644 index 0000000..543abc6 --- /dev/null +++ b/packages/protocol/src/codes.ts @@ -0,0 +1,11 @@ +export const ERROR_CODES = [ + "BAD_REQUEST", + "ROOM_NOT_FOUND", + "ROOM_FULL", + "NAME_TAKEN", + "INVALID_TOKEN", + "FORBIDDEN", + "INTERNAL", +] as const; + +export type ErrorCode = (typeof ERROR_CODES)[number]; diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts new file mode 100644 index 0000000..6d8dc77 --- /dev/null +++ b/packages/protocol/src/events.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { ERROR_CODES } from "./codes.js"; + +export const Channel = z.enum(["ic", "ooc"]); +export type Channel = z.infer; + +export const PlayerPublic = z.object({ + playerId: z.string(), + displayName: z.string(), + role: z.enum(["host", "player"]), + online: z.boolean(), +}); +export type PlayerPublic = z.infer; + +export const ChatMessage = z.object({ + messageId: z.string(), + clientId: z.string(), + playerId: z.string(), + displayName: z.string(), + channel: Channel, + characterName: z.string().nullable(), + text: z.string(), +}); +export type ChatMessage = z.infer; + +export const RoomSnapshot = z.object({ + seq: z.number().int().nonnegative(), + roomName: z.string(), + players: z.array(PlayerPublic), + scene: z.null(), + characters: z.array(z.unknown()), + pendingRollRequests: z.array(z.unknown()), + transcript: z.array(ChatMessage), + activeStream: z.null(), + gmStatus: z.literal("idle"), +}); +export type RoomSnapshot = z.infer; + +export const RoomJoined = z.object({ + roomCode: z.string(), + playerId: z.string(), + playerToken: z.string(), + resumed: z.boolean(), + isHost: z.boolean(), + protocolVersion: z.number().int(), +}); +export type RoomJoined = z.infer; + +export const RoomClosed = z.object({ reason: z.string() }); +export type RoomClosed = z.infer; + +export const Presence = z.object({ players: z.array(PlayerPublic) }); +export type Presence = z.infer; + +export const ErrorPayload = z.object({ + code: z.enum(ERROR_CODES), + message: z.string(), + refId: z.string().nullable(), +}); +export type ErrorPayload = z.infer; + +// ---- client → server requests ---- +export const RoomJoinReq = z.object({ + roomCode: z.string().min(1), + displayName: z.string().min(1).max(40), + playerToken: z.string().optional(), +}); +export type RoomJoinReq = z.infer; + +export const ChatSend = z.object({ + text: z.string().min(1).max(2000), + channel: Channel, +}); +export type ChatSend = z.infer; + +export const RoomLeave = z.object({}); +export const RoomClose = z.object({}); +export const SystemPing = z.object({}); + +export const C2S_SCHEMAS: Record = { + "room.join": RoomJoinReq, + "room.leave": RoomLeave, + "room.close": RoomClose, + "chat.send": ChatSend, + "system.ping": SystemPing, +}; + +export const S2C_SCHEMAS: Record = { + "room.joined": RoomJoined, + "room.snapshot": RoomSnapshot, + "room.closed": RoomClosed, + "presence.update": Presence, + "chat.message": ChatMessage, + "system.pong": z.object({}), + error: ErrorPayload, +}; + +export function parsePayload( + type: string, + payload: unknown, + table: Record, +): { ok: true; data: unknown } | { ok: false; error: string } { + const schema = table[type]; + if (!schema) return { ok: false, error: "unknown type" }; + const result = schema.safeParse(payload); + if (!result.success) return { ok: false, error: result.error.message }; + return { ok: true, data: result.data }; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 8a749ff..867def7 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1 +1,3 @@ export * from "./envelope.js"; +export * from "./codes.js"; +export * from "./events.js"; diff --git a/packages/protocol/test/events.test.ts b/packages/protocol/test/events.test.ts new file mode 100644 index 0000000..8e7bcbb --- /dev/null +++ b/packages/protocol/test/events.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { + C2S_SCHEMAS, + S2C_SCHEMAS, + parsePayload, + ChatSend, + RoomJoinReq, +} from "../src/index.js"; + +describe("event catalogue", () => { + it("accepts a valid chat.send", () => { + expect(ChatSend.safeParse({ text: "hello", channel: "ic" }).success).toBe(true); + }); + + it("rejects chat.send over 2000 chars", () => { + expect(ChatSend.safeParse({ text: "x".repeat(2001), channel: "ic" }).success).toBe(false); + }); + + it("rejects chat.send with a bad channel", () => { + expect(ChatSend.safeParse({ text: "hi", channel: "shout" }).success).toBe(false); + }); + + it("room.join token is optional", () => { + expect(RoomJoinReq.safeParse({ roomCode: "ABC234", displayName: "Kira" }).success).toBe(true); + expect( + RoomJoinReq.safeParse({ roomCode: "ABC234", displayName: "Kira", playerToken: "t" }).success, + ).toBe(true); + }); + + it("parsePayload validates a known type", () => { + const r = parsePayload("chat.send", { text: "hi", channel: "ooc" }, C2S_SCHEMAS); + expect(r.ok).toBe(true); + }); + + it("parsePayload reports unknown types without throwing", () => { + const r = parsePayload("map.move", { x: 1 }, C2S_SCHEMAS); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("unknown type"); + }); + + it("every M0 c2s and s2c type has a schema", () => { + expect(Object.keys(C2S_SCHEMAS).sort()).toEqual( + ["chat.send", "room.close", "room.join", "room.leave", "system.ping"].sort(), + ); + expect(Object.keys(S2C_SCHEMAS).sort()).toEqual( + [ + "chat.message", + "error", + "presence.update", + "room.closed", + "room.joined", + "room.snapshot", + "system.pong", + ].sort(), + ); + }); + + it("object schemas strip unknown keys (additive-evolution rule)", () => { + const parsed = ChatSend.parse({ text: "hi", channel: "ic", futureField: 42 }); + expect("futureField" in parsed).toBe(false); + }); +}); From a04cff9d44a5175e5b4d4be04ccfce72523f93fe Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:45:38 +0800 Subject: [PATCH 06/15] feat(server): config, app skeleton, /healthz, entry with SIGTERM drain --- packages/server/package.json | 28 + packages/server/src/app.ts | 19 + packages/server/src/config.ts | 16 + packages/server/src/index.ts | 27 + packages/server/test/config.test.ts | 21 + packages/server/test/healthz.test.ts | 16 + packages/server/tsconfig.json | 7 + packages/server/vitest.config.ts | 8 + pnpm-lock.yaml | 1644 +++++++++++++++++++++++++- 9 files changed, 1751 insertions(+), 35 deletions(-) create mode 100644 packages/server/package.json create mode 100644 packages/server/src/app.ts create mode 100644 packages/server/src/config.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/test/config.test.ts create mode 100644 packages/server/test/healthz.test.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/server/vitest.config.ts diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..5eff415 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,28 @@ +{ + "name": "@gm/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --out-dir dist", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@fastify/static": "^8.0.0", + "@fastify/websocket": "^11.0.0", + "@gm/protocol": "workspace:*", + "fastify": "^5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "tsup": "^8.3.0", + "tsx": "^4.19.0", + "vitest": "^2.1.0", + "ws": "^8.18.0" + } +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 0000000..514fb07 --- /dev/null +++ b/packages/server/src/app.ts @@ -0,0 +1,19 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import type { Config } from "./config.js"; + +export interface AppDeps { + config: Config; +} + +export function buildApp(deps: AppDeps): FastifyInstance { + const app = Fastify({ logger: false }); + + app.get("/healthz", async () => ({ + status: "ok", + uptime: process.uptime(), + liveRooms: 0, + })); + + void deps; // config consumed by routes/ws added in later tasks + return app; +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 0000000..70ed21b --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +const ConfigSchema = z.object({ + PORT: z.coerce.number().int().positive().default(8787), + DATA_DIR: z.string().default("./data"), + WS_PING_MS: z.coerce.number().int().positive().default(30000), + WS_MAX_MISSED_PONGS: z.coerce.number().int().positive().default(2), + IDLE_EVICT_MS: z.coerce.number().int().positive().default(900000), + ROOM_MAX_PLAYERS: z.coerce.number().int().positive().default(6), +}); + +export type Config = z.infer; + +export function loadConfig(env: NodeJS.ProcessEnv): Config { + return ConfigSchema.parse(env); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..51ea15a --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,27 @@ +import { buildApp } from "./app.js"; +import { loadConfig } from "./config.js"; + +const config = loadConfig(process.env); +const app = buildApp({ config }); + +async function main() { + await app.listen({ port: config.PORT, host: "0.0.0.0" }); + // eslint-disable-next-line no-console + console.log(`[gm] listening on :${config.PORT}`); +} + +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[gm] ${signal} received, draining`); + await app.close(); + process.exit(0); +} +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts new file mode 100644 index 0000000..704a689 --- /dev/null +++ b/packages/server/test/config.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + it("applies defaults on empty env", () => { + const c = loadConfig({}); + expect(c.PORT).toBe(8787); + expect(c.DATA_DIR).toBe("./data"); + expect(c.WS_PING_MS).toBe(30000); + expect(c.WS_MAX_MISSED_PONGS).toBe(2); + expect(c.ROOM_MAX_PLAYERS).toBe(6); + }); + + it("coerces numeric strings", () => { + expect(loadConfig({ PORT: "9000" }).PORT).toBe(9000); + }); + + it("rejects a non-numeric PORT", () => { + expect(() => loadConfig({ PORT: "notaport" })).toThrow(); + }); +}); diff --git a/packages/server/test/healthz.test.ts b/packages/server/test/healthz.test.ts new file mode 100644 index 0000000..e18b049 --- /dev/null +++ b/packages/server/test/healthz.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +describe("GET /healthz", () => { + it("returns 200 with status ok and a numeric uptime", async () => { + const app = buildApp({ config: loadConfig({}) }); + const res = await app.inject({ method: "GET", url: "/healthz" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.status).toBe("ok"); + expect(typeof body.uptime).toBe("number"); + expect(typeof body.liveRooms).toBe("number"); + await app.close(); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..9241f01 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 0000000..701b71e --- /dev/null +++ b/packages/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + server: { deps: { inline: [/@gm\/protocol/] } }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 685e90b..e645f1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,44 @@ importers: devDependencies: vitest: specifier: ^2.1.0 - version: 2.1.9 + version: 2.1.9(@types/node@22.20.0) + + packages/server: + dependencies: + '@fastify/static': + specifier: ^8.0.0 + version: 8.3.0 + '@fastify/websocket': + specifier: ^11.0.0 + version: 11.2.0 + '@gm/protocol': + specifier: workspace:* + version: link:../protocol + fastify: + specifier: ^5.0.0 + version: 5.9.0 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + '@types/ws': + specifier: ^8.5.0 + version: 8.18.1 + tsup: + specifier: ^8.3.0 + version: 8.5.1(postcss@8.5.16)(tsx@4.22.4)(typescript@5.9.3) + tsx: + specifier: ^4.19.0 + version: 4.22.4 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.20.0) + ws: + specifier: ^8.18.0 + version: 8.21.0 packages: @@ -33,141 +70,504 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@8.3.0': + resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + + '@fastify/websocket@11.2.0': + resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -309,6 +709,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -338,6 +744,25 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -346,10 +771,34 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -366,6 +815,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -377,11 +830,34 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + concurrently@9.2.3: resolution: {integrity: sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==} engines: {node: '>=18'} hasBin: true + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -395,9 +871,23 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -406,10 +896,23 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -417,6 +920,50 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.9.0: + resolution: {integrity: sha512-VMS5lE0zj+MZlJpQa3Qv5iGjfun0H2N7VRgoBwpcTNQ2bdIQpv7fDpb+HGteGbicBsGkzGS+X+hdx9mmrfWuHQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -426,31 +973,127 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -458,14 +1101,96 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -474,6 +1199,39 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shell-quote@1.8.4: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} @@ -481,24 +1239,54 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -507,12 +1295,27 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -525,18 +1328,62 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -598,6 +1445,11 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -607,6 +1459,21 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -615,86 +1482,311 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + '@esbuild/linux-riscv64@0.21.5': + optional: true -snapshots: + '@esbuild/linux-riscv64@0.27.7': + optional: true - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openharmony-arm64@0.28.1': optional: true '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.3 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@8.3.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 0.5.4 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 11.1.0 + + '@fastify/websocket@11.2.0': + dependencies: + duplexify: 4.1.3 + fastify-plugin: 5.1.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/ms@2.0.2': {} + + '@pinojs/redact@0.4.0': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -772,6 +1864,14 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -779,13 +1879,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21 + vite: 5.4.21(@types/node@22.20.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -812,14 +1912,49 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + abstract-logging@2.0.1: {} + + acorn@8.17.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + any-promise@1.3.0: {} + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + cac@6.7.14: {} chai@5.3.3: @@ -837,6 +1972,10 @@ snapshots: check-error@2.1.3: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -849,6 +1988,8 @@ snapshots: color-name@1.1.4: {} + commander@4.1.1: {} + concurrently@9.2.3: dependencies: chalk: 4.1.2 @@ -858,14 +1999,45 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + confbox@0.1.8: {} + + consola@3.4.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 deep-eql@5.0.2: {} + depd@2.0.0: {} + + dequal@2.0.3: {} + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + emoji-regex@8.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -894,47 +2066,325 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 expect-type@1.4.0: {} + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.3 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.3: {} + + fastify-plugin@5.1.0: {} + + fastify@5.9.0: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.1 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true get-caller-file@2.0.5: {} + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + has-flag@4.0.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + inherits@2.0.4: {} + + ipaddr.js@2.4.0: {} + is-fullwidth-code-point@3.0.0: {} + isexe@2.0.0: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + joycon@3.1.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + loupe@3.2.1: {} + lru-cache@11.5.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@3.0.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minipass@7.1.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.15: {} + object-assign@4.1.1: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} picocolors@1.1.1: {} + picomatch@4.0.4: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.16)(tsx@4.22.4): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.16 + tsx: 4.22.4 + postcss@8.5.16: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + quick-format-unescaped@4.0.4: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + + resolve-from@5.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 @@ -970,26 +2420,76 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shell-quote@1.8.4: {} siginfo@2.0.0: {} + signal-exit@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + source-map@0.7.6: {} + + split2@4.2.0: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + stream-shift@1.0.3: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -998,29 +2498,92 @@ snapshots: dependencies: has-flag: 4.0.0 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} tinyspy@3.0.2: {} + toad-cache@3.7.1: {} + + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} + ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + tsup@8.5.1(postcss@8.5.16)(tsx@4.22.4)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.16)(tsx@4.22.4) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.16 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} - vite-node@2.1.9: + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + util-deprecate@1.0.2: {} + + vite-node@2.1.9(@types/node@22.20.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21 + vite: 5.4.21(@types/node@22.20.0) transitivePeerDependencies: - '@types/node' - less @@ -1032,18 +2595,19 @@ snapshots: - supports-color - terser - vite@5.4.21: + vite@5.4.21(@types/node@22.20.0): dependencies: esbuild: 0.21.5 postcss: 8.5.16 rollup: 4.62.2 optionalDependencies: + '@types/node': 22.20.0 fsevents: 2.3.3 - vitest@2.1.9: + vitest@2.1.9(@types/node@22.20.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -1059,9 +2623,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21 - vite-node: 2.1.9 + vite: 5.4.21(@types/node@22.20.0) + vite-node: 2.1.9(@types/node@22.20.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.0 transitivePeerDependencies: - less - lightningcss @@ -1073,6 +2639,10 @@ snapshots: - supports-color - terser + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -1084,6 +2654,10 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + + ws@8.21.0: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} From 9fc25d92f8f291bb0af8133ba77cb0a7f9e2c4c2 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:46:56 +0800 Subject: [PATCH 07/15] feat(server): atomic writes + table file store (room/players/transcript) --- packages/server/src/store/atomic.ts | 10 +++ packages/server/src/store/paths.ts | 14 +++++ packages/server/src/store/tableStore.ts | 84 +++++++++++++++++++++++++ packages/server/test/store.test.ts | 78 +++++++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 packages/server/src/store/atomic.ts create mode 100644 packages/server/src/store/paths.ts create mode 100644 packages/server/src/store/tableStore.ts create mode 100644 packages/server/test/store.test.ts diff --git a/packages/server/src/store/atomic.ts b/packages/server/src/store/atomic.ts new file mode 100644 index 0000000..f24d48d --- /dev/null +++ b/packages/server/src/store/atomic.ts @@ -0,0 +1,10 @@ +import { writeFile, rename, mkdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname } from "node:path"; + +export async function writeFileAtomic(path: string, data: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp-${randomUUID()}`; + await writeFile(tmp, data, "utf8"); + await rename(tmp, path); +} diff --git a/packages/server/src/store/paths.ts b/packages/server/src/store/paths.ts new file mode 100644 index 0000000..57061e9 --- /dev/null +++ b/packages/server/src/store/paths.ts @@ -0,0 +1,14 @@ +import { join } from "node:path"; + +export function tableDir(dataDir: string, code: string): string { + return join(dataDir, "tables", code); +} +export function roomJsonPath(dataDir: string, code: string): string { + return join(tableDir(dataDir, code), "room.json"); +} +export function playersJsonPath(dataDir: string, code: string): string { + return join(tableDir(dataDir, code), "players.json"); +} +export function transcriptPath(dataDir: string, code: string): string { + return join(tableDir(dataDir, code), "transcript.jsonl"); +} diff --git a/packages/server/src/store/tableStore.ts b/packages/server/src/store/tableStore.ts new file mode 100644 index 0000000..29e299e --- /dev/null +++ b/packages/server/src/store/tableStore.ts @@ -0,0 +1,84 @@ +import { readFile, appendFile, mkdir } from "node:fs/promises"; +import { z } from "zod"; +import { ChatMessage } from "@gm/protocol"; +import { writeFileAtomic } from "./atomic.js"; +import { roomJsonPath, playersJsonPath, transcriptPath, tableDir } from "./paths.js"; + +export const RoomRecord = z.object({ + code: z.string(), + phase: z.enum(["active", "ended"]), + hostPlayerId: z.string(), + createdAt: z.number().int(), + lastActiveAt: z.number().int(), + seq: z.number().int().nonnegative(), + schemaVersion: z.literal(1), +}); +export type RoomRecord = z.infer; + +export const PlayerRecord = z.object({ + playerId: z.string(), + name: z.string(), + role: z.enum(["host", "player"]), + tokenHash: z.string(), + createdAt: z.number().int(), + lastSeenAt: z.number().int(), +}); +export type PlayerRecord = z.infer; + +async function readJsonOrNull(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} + +export async function saveRoomRecord(dataDir: string, rec: RoomRecord): Promise { + await writeFileAtomic(roomJsonPath(dataDir, rec.code), JSON.stringify(rec, null, 2)); +} + +export async function loadRoomRecord(dataDir: string, code: string): Promise { + const raw = await readJsonOrNull(roomJsonPath(dataDir, code)); + if (raw === null) return null; + return RoomRecord.parse(raw); +} + +export async function savePlayers( + dataDir: string, + code: string, + players: PlayerRecord[], +): Promise { + await writeFileAtomic(playersJsonPath(dataDir, code), JSON.stringify(players, null, 2)); +} + +export async function loadPlayers(dataDir: string, code: string): Promise { + const raw = await readJsonOrNull(playersJsonPath(dataDir, code)); + if (raw === null) return []; + return z.array(PlayerRecord).parse(raw); +} + +export async function appendTranscript( + dataDir: string, + code: string, + msg: ChatMessage, +): Promise { + await mkdir(tableDir(dataDir, code), { recursive: true }); + await appendFile(transcriptPath(dataDir, code), JSON.stringify(msg) + "\n", "utf8"); +} + +export async function tailTranscript( + dataDir: string, + code: string, + n: number, +): Promise { + let text: string; + try { + text = await readFile(transcriptPath(dataDir, code), "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + throw err; + } + const lines = text.split("\n").filter((l) => l.length > 0); + return lines.slice(-n).map((l) => ChatMessage.parse(JSON.parse(l))); +} diff --git a/packages/server/test/store.test.ts b/packages/server/test/store.test.ts new file mode 100644 index 0000000..173a8e8 --- /dev/null +++ b/packages/server/test/store.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + saveRoomRecord, + loadRoomRecord, + savePlayers, + loadPlayers, + appendTranscript, + tailTranscript, + type RoomRecord, + type PlayerRecord, +} from "../src/store/tableStore.js"; +import type { ChatMessage } from "@gm/protocol"; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-store-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const rec: RoomRecord = { + code: "ABC234", + phase: "active", + hostPlayerId: "p1", + createdAt: 1, + lastActiveAt: 1, + seq: 0, + schemaVersion: 1, +}; + +function chat(messageId: string, text: string): ChatMessage { + return { + messageId, + clientId: "c", + playerId: "p1", + displayName: "Kira", + channel: "ic", + characterName: null, + text, + }; +} + +describe("tableStore", () => { + it("round-trips a room record", async () => { + await saveRoomRecord(dir, rec); + expect(await loadRoomRecord(dir, "ABC234")).toEqual(rec); + }); + + it("returns null for an absent room", async () => { + expect(await loadRoomRecord(dir, "ZZZZZZ")).toBeNull(); + }); + + it("round-trips the player roster", async () => { + const players: PlayerRecord[] = [ + { playerId: "p1", name: "Kira", role: "host", tokenHash: "h", createdAt: 1, lastSeenAt: 1 }, + ]; + await savePlayers(dir, "ABC234", players); + expect(await loadPlayers(dir, "ABC234")).toEqual(players); + }); + + it("returns [] for an absent roster", async () => { + expect(await loadPlayers(dir, "ZZZZZZ")).toEqual([]); + }); + + it("tails only the last n transcript lines", async () => { + for (let i = 0; i < 5; i++) await appendTranscript(dir, "ABC234", chat(`m${i}`, `line ${i}`)); + const tail = await tailTranscript(dir, "ABC234", 2); + expect(tail.map((m) => m.messageId)).toEqual(["m3", "m4"]); + }); + + it("returns [] tailing an absent transcript", async () => { + expect(await tailTranscript(dir, "ZZZZZZ", 10)).toEqual([]); + }); +}); From d9f76d8faa4ac9d77b8195a0d7d81c235575b50c Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:47:39 +0800 Subject: [PATCH 08/15] feat(server): account-less identity (token mint/hash/verify + roster helpers) --- packages/server/src/identity/roster.ts | 11 ++++++++ packages/server/src/identity/token.ts | 20 ++++++++++++++ packages/server/test/identity.test.ts | 37 ++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 packages/server/src/identity/roster.ts create mode 100644 packages/server/src/identity/token.ts create mode 100644 packages/server/test/identity.test.ts diff --git a/packages/server/src/identity/roster.ts b/packages/server/src/identity/roster.ts new file mode 100644 index 0000000..08b86f5 --- /dev/null +++ b/packages/server/src/identity/roster.ts @@ -0,0 +1,11 @@ +import type { PlayerRecord } from "../store/tableStore.js"; +import { verifyToken } from "./token.js"; + +export function nameTaken(players: PlayerRecord[], name: string): boolean { + const lower = name.toLowerCase(); + return players.some((p) => p.name.toLowerCase() === lower); +} + +export function findByToken(players: PlayerRecord[], token: string): PlayerRecord | null { + return players.find((p) => verifyToken(token, p.tokenHash)) ?? null; +} diff --git a/packages/server/src/identity/token.ts b/packages/server/src/identity/token.ts new file mode 100644 index 0000000..43bd027 --- /dev/null +++ b/packages/server/src/identity/token.ts @@ -0,0 +1,20 @@ +import { randomUUID, randomBytes, createHash, timingSafeEqual } from "node:crypto"; + +export function newPlayerId(): string { + return randomUUID(); +} + +export function mintToken(): string { + return randomBytes(24).toString("base64url"); // 192-bit +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function verifyToken(token: string, hash: string): boolean { + const a = Buffer.from(hashToken(token), "hex"); + const b = Buffer.from(hash, "hex"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/packages/server/test/identity.test.ts b/packages/server/test/identity.test.ts new file mode 100644 index 0000000..cbadda7 --- /dev/null +++ b/packages/server/test/identity.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { newPlayerId, mintToken, hashToken, verifyToken } from "../src/identity/token.js"; +import { nameTaken, findByToken } from "../src/identity/roster.js"; +import type { PlayerRecord } from "../src/store/tableStore.js"; + +describe("token", () => { + it("mints unique playerIds", () => { + expect(newPlayerId()).not.toBe(newPlayerId()); + }); + it("mints high-entropy tokens", () => { + const t = mintToken(); + expect(t.length).toBeGreaterThanOrEqual(32); + expect(mintToken()).not.toBe(t); + }); + it("hash is stable and verifies", () => { + const t = mintToken(); + const h = hashToken(t); + expect(hashToken(t)).toBe(h); + expect(verifyToken(t, h)).toBe(true); + expect(verifyToken(mintToken(), h)).toBe(false); + }); +}); + +describe("roster", () => { + const players: PlayerRecord[] = [ + { playerId: "p1", name: "Kira", role: "host", tokenHash: hashToken("tok-1"), createdAt: 1, lastSeenAt: 1 }, + ]; + it("nameTaken is case-insensitive", () => { + expect(nameTaken(players, "kira")).toBe(true); + expect(nameTaken(players, "KIRA")).toBe(true); + expect(nameTaken(players, "Bob")).toBe(false); + }); + it("findByToken matches the hashed seat", () => { + expect(findByToken(players, "tok-1")?.playerId).toBe("p1"); + expect(findByToken(players, "wrong")).toBeNull(); + }); +}); From 76d06bcf20744f3ca380dd0c8dac9ba1fc079586 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 15:48:51 +0800 Subject: [PATCH 09/15] feat(server): room code generator + in-memory Room actor (seq/broadcast/snapshot/chat) --- packages/server/src/rooms/code.ts | 17 ++++ packages/server/src/rooms/room.ts | 155 ++++++++++++++++++++++++++++++ packages/server/test/code.test.ts | 19 ++++ packages/server/test/room.test.ts | 103 ++++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 packages/server/src/rooms/code.ts create mode 100644 packages/server/src/rooms/room.ts create mode 100644 packages/server/test/code.test.ts create mode 100644 packages/server/test/room.test.ts diff --git a/packages/server/src/rooms/code.ts b/packages/server/src/rooms/code.ts new file mode 100644 index 0000000..0f3b3bd --- /dev/null +++ b/packages/server/src/rooms/code.ts @@ -0,0 +1,17 @@ +import { randomInt } from "node:crypto"; + +// Crockford base32 minus visually ambiguous 0 O 1 I L U. +export const ROOM_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; + +function cryptoRng(): number { + return randomInt(0, 1_000_000) / 1_000_000; +} + +export function generateCode(rng: () => number = cryptoRng): string { + let out = ""; + for (let i = 0; i < 6; i++) { + const idx = Math.min(ROOM_ALPHABET.length - 1, Math.floor(rng() * ROOM_ALPHABET.length)); + out += ROOM_ALPHABET[idx]; + } + return out; +} diff --git a/packages/server/src/rooms/room.ts b/packages/server/src/rooms/room.ts new file mode 100644 index 0000000..9dce4ff --- /dev/null +++ b/packages/server/src/rooms/room.ts @@ -0,0 +1,155 @@ +import { + makeEnvelope, + type ChatMessage, + type PlayerPublic, + type RoomSnapshot, + type Channel, +} from "@gm/protocol"; +import { newPlayerId } from "../identity/token.js"; +import { appendTranscript, saveRoomRecord, savePlayers, type PlayerRecord, type RoomRecord } from "../store/tableStore.js"; +import type { Config } from "../config.js"; + +export interface ClientSocket { + send(data: string): void; +} + +const CHAT_RING = 100; + +export class Room { + private rec: RoomRecord; + private roster: PlayerRecord[]; + private recentChat: ChatMessage[]; + private sockets = new Map>(); + private readonly deps: { config: Config; dataDir: string }; + + constructor( + record: RoomRecord, + players: PlayerRecord[], + recentChat: ChatMessage[], + deps: { config: Config; dataDir: string }, + ) { + this.rec = record; + this.roster = players; + this.recentChat = recentChat.slice(-CHAT_RING); + this.deps = deps; + } + + get code(): string { + return this.rec.code; + } + get record(): RoomRecord { + return this.rec; + } + get players(): PlayerRecord[] { + return this.roster; + } + get lastActiveAt(): number { + return this.rec.lastActiveAt; + } + get socketCount(): number { + let n = 0; + for (const set of this.sockets.values()) n += set.size; + return n; + } + + addPlayer(p: PlayerRecord): void { + this.roster.push(p); + } + findPlayer(playerId: string): PlayerRecord | undefined { + return this.roster.find((p) => p.playerId === playerId); + } + + subscribe(playerId: string, socket: ClientSocket): void { + let set = this.sockets.get(playerId); + if (!set) { + set = new Set(); + this.sockets.set(playerId, set); + } + set.add(socket); + } + + unsubscribe(playerId: string, socket: ClientSocket): void { + const set = this.sockets.get(playerId); + if (!set) return; + set.delete(socket); + if (set.size === 0) this.sockets.delete(playerId); + } + + private isOnline(playerId: string): boolean { + const set = this.sockets.get(playerId); + return !!set && set.size > 0; + } + + publicPlayers(): PlayerPublic[] { + return this.roster.map((p) => ({ + playerId: p.playerId, + displayName: p.name, + role: p.role, + online: this.isOnline(p.playerId), + })); + } + + private nextSeq(): number { + this.rec.seq += 1; + return this.rec.seq; + } + + sendTo(socket: ClientSocket, type: string, payload: unknown): void { + socket.send(JSON.stringify(makeEnvelope({ type, room: this.rec.code, payload }))); + } + + broadcast(type: string, payload: unknown): void { + const seq = this.nextSeq(); + const wire = JSON.stringify(makeEnvelope({ type, room: this.rec.code, payload, seq })); + for (const set of this.sockets.values()) { + for (const socket of set) socket.send(wire); + } + } + + buildSnapshot(): RoomSnapshot { + return { + seq: this.rec.seq, + roomName: this.rec.code, + players: this.publicPlayers(), + scene: null, + characters: [], + pendingRollRequests: [], + transcript: this.recentChat.slice(-CHAT_RING), + activeStream: null, + gmStatus: "idle", + }; + } + + async appendChat( + playerId: string, + channel: Channel, + text: string, + clientId: string, + ): Promise { + const player = this.findPlayer(playerId); + const msg: ChatMessage = { + messageId: newPlayerId(), + clientId, + playerId, + displayName: player?.name ?? "unknown", + channel, + characterName: null, + text, + }; + this.recentChat.push(msg); + if (this.recentChat.length > CHAT_RING) this.recentChat.shift(); + this.broadcast("chat.message", msg); + this.rec.lastActiveAt = Date.now(); + await appendTranscript(this.deps.dataDir, this.rec.code, msg); + await saveRoomRecord(this.deps.dataDir, this.rec); + return msg; + } + + async flush(): Promise { + await saveRoomRecord(this.deps.dataDir, this.rec); + } + + async persistPlayers(): Promise { + await savePlayers(this.deps.dataDir, this.rec.code, this.roster); + } +} diff --git a/packages/server/test/code.test.ts b/packages/server/test/code.test.ts new file mode 100644 index 0000000..dc06839 --- /dev/null +++ b/packages/server/test/code.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { ROOM_ALPHABET, generateCode } from "../src/rooms/code.js"; + +describe("generateCode", () => { + it("is 6 chars from the Crockford-minus-ambiguous alphabet", () => { + for (let i = 0; i < 200; i++) { + const code = generateCode(); + expect(code).toMatch(/^[0-9A-Z]{6}$/); + for (const ch of code) expect(ROOM_ALPHABET).toContain(ch); + } + }); + it("excludes ambiguous characters 0 O 1 I L U", () => { + for (const ch of "01OILU") expect(ROOM_ALPHABET).not.toContain(ch); + }); + it("is deterministic under an injected rng", () => { + const rng = () => 0; // always first letter + expect(generateCode(rng)).toBe(ROOM_ALPHABET[0]!.repeat(6)); + }); +}); diff --git a/packages/server/test/room.test.ts b/packages/server/test/room.test.ts new file mode 100644 index 0000000..e0fe4e9 --- /dev/null +++ b/packages/server/test/room.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseEnvelope } from "@gm/protocol"; +import { Room, type ClientSocket } from "../src/rooms/room.js"; +import { loadRoomRecord, type RoomRecord, type PlayerRecord } from "../src/store/tableStore.js"; +import { loadConfig } from "../src/config.js"; + +class FakeSocket implements ClientSocket { + sent: string[] = []; + send(data: string) { + this.sent.push(data); + } + types() { + return this.sent.map((s) => { + const r = parseEnvelope(s); + return r.ok ? r.envelope.type : "BAD"; + }); + } +} + +let dir: string; +const config = loadConfig({}); +const baseRec: RoomRecord = { + code: "ABC234", + phase: "active", + hostPlayerId: "p1", + createdAt: 1, + lastActiveAt: 1, + seq: 0, + schemaVersion: 1, +}; +const host: PlayerRecord = { + playerId: "p1", + name: "Kira", + role: "host", + tokenHash: "h", + createdAt: 1, + lastSeenAt: 1, +}; + +function newRoom(): Room { + return new Room({ ...baseRec }, [{ ...host }], [], { config, dataDir: dir }); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-room-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("Room", () => { + it("subscribe/unsubscribe drives the socket refcount and online flag", () => { + const room = newRoom(); + const s = new FakeSocket(); + expect(room.publicPlayers()[0]!.online).toBe(false); + room.subscribe("p1", s); + expect(room.socketCount).toBe(1); + expect(room.publicPlayers()[0]!.online).toBe(true); + room.unsubscribe("p1", s); + expect(room.socketCount).toBe(0); + expect(room.publicPlayers()[0]!.online).toBe(false); + }); + + it("broadcast assigns a monotonic seq and reaches every socket", () => { + const room = newRoom(); + const a = new FakeSocket(); + const b = new FakeSocket(); + room.subscribe("p1", a); + room.subscribe("p1", b); + room.broadcast("presence.update", { players: room.publicPlayers() }); + const ea = parseEnvelope(a.sent[0]!); + const eb = parseEnvelope(b.sent[0]!); + expect(ea.ok && eb.ok).toBe(true); + if (ea.ok && eb.ok) { + expect(ea.envelope.seq).toBe(1); + expect(eb.envelope.seq).toBe(1); + } + }); + + it("buildSnapshot carries the current seq watermark and roster", () => { + const room = newRoom(); + room.broadcast("presence.update", { players: [] }); // seq -> 1 + const snap = room.buildSnapshot(); + expect(snap.seq).toBe(1); + expect(snap.gmStatus).toBe("idle"); + expect(snap.players[0]!.playerId).toBe("p1"); + }); + + it("appendChat broadcasts, persists the transcript, and bumps the persisted seq", async () => { + const room = newRoom(); + const s = new FakeSocket(); + room.subscribe("p1", s); + const msg = await room.appendChat("p1", "ic", "hello table", "client-1"); + expect(msg.text).toBe("hello table"); + expect(s.types()).toContain("chat.message"); + const rec = await loadRoomRecord(dir, "ABC234"); + expect(rec!.seq).toBe(room.record.seq); + expect(rec!.seq).toBeGreaterThan(0); + }); +}); From c29bfcfa67aafc28daba01b7cf7235dd9d799ea1 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 16:25:31 +0800 Subject: [PATCH 10/15] feat(server): RoomRegistry create/rehydrate/evict + idle sweep + peek --- packages/server/src/rooms/registry.ts | 117 ++++++++++++++++++++++++++ packages/server/test/registry.test.ts | 59 +++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/server/src/rooms/registry.ts create mode 100644 packages/server/test/registry.test.ts diff --git a/packages/server/src/rooms/registry.ts b/packages/server/src/rooms/registry.ts new file mode 100644 index 0000000..53d40c1 --- /dev/null +++ b/packages/server/src/rooms/registry.ts @@ -0,0 +1,117 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { Room } from "./room.js"; +import { generateCode } from "./code.js"; +import { newPlayerId, mintToken, hashToken } from "../identity/token.js"; +import { + saveRoomRecord, + savePlayers, + loadRoomRecord, + loadPlayers, + tailTranscript, + type RoomRecord, + type PlayerRecord, +} from "../store/tableStore.js"; +import type { Config } from "../config.js"; + +export class RoomRegistry { + private rooms = new Map(); + private readonly config: Config; + private readonly dataDir: string; + + constructor(deps: { config: Config; dataDir: string }) { + this.config = deps.config; + this.dataDir = deps.dataDir; + } + + get size(): number { + return this.rooms.size; + } + + private async reserveCode(): Promise { + await mkdir(join(this.dataDir, "tables"), { recursive: true }); + for (let i = 0; i < 5; i++) { + const code = generateCode(); + try { + await mkdir(join(this.dataDir, "tables", code), { recursive: false }); + return code; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") continue; + throw err; + } + } + throw new Error("could not allocate a unique room code"); + } + + async createRoom( + hostName: string, + ): Promise<{ roomCode: string; playerId: string; token: string }> { + const code = await this.reserveCode(); + const now = Date.now(); + const playerId = newPlayerId(); + const token = mintToken(); + + const record: RoomRecord = { + code, + phase: "active", + hostPlayerId: playerId, + createdAt: now, + lastActiveAt: now, + seq: 0, + schemaVersion: 1, + }; + const host: PlayerRecord = { + playerId, + name: hostName, + role: "host", + tokenHash: hashToken(token), + createdAt: now, + lastSeenAt: now, + }; + await saveRoomRecord(this.dataDir, record); + await savePlayers(this.dataDir, code, [host]); + + this.rooms.set(code, new Room(record, [host], [], { config: this.config, dataDir: this.dataDir })); + return { roomCode: code, playerId, token }; + } + + async getOrRehydrate(code: string): Promise { + const existing = this.rooms.get(code); + if (existing) return existing; + const record = await loadRoomRecord(this.dataDir, code); + if (!record) return null; + const players = await loadPlayers(this.dataDir, code); + const recent = await tailTranscript(this.dataDir, code, 100); + const room = new Room(record, players, recent, { config: this.config, dataDir: this.dataDir }); + this.rooms.set(code, room); + return room; + } + + peek(code: string): Room | null { + return this.rooms.get(code) ?? null; + } + + list(): { code: string; phase: string; lastActiveAt: number; socketCount: number }[] { + return [...this.rooms.values()].map((r) => ({ + code: r.code, + phase: r.record.phase, + lastActiveAt: r.lastActiveAt, + socketCount: r.socketCount, + })); + } + + async evict(code: string): Promise { + const room = this.rooms.get(code); + if (!room) return; + await room.flush(); + this.rooms.delete(code); + } + + async sweepIdle(now: number): Promise { + for (const room of [...this.rooms.values()]) { + if (room.socketCount === 0 && now - room.lastActiveAt > this.config.IDLE_EVICT_MS) { + await this.evict(room.code); + } + } + } +} diff --git a/packages/server/test/registry.test.ts b/packages/server/test/registry.test.ts new file mode 100644 index 0000000..53a5e38 --- /dev/null +++ b/packages/server/test/registry.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RoomRegistry } from "../src/rooms/registry.js"; +import { loadConfig } from "../src/config.js"; + +let dir: string; +function newRegistry(overrides: Record = {}) { + return new RoomRegistry({ config: loadConfig(overrides), dataDir: dir }); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-reg-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("RoomRegistry", () => { + it("creates a room with a host seat and a token", async () => { + const reg = newRegistry(); + const { roomCode, playerId, token } = await reg.createRoom("Kira"); + expect(roomCode).toMatch(/^[0-9A-Z]{6}$/); + expect(playerId.length).toBeGreaterThan(0); + expect(token.length).toBeGreaterThan(0); + expect(reg.size).toBe(1); + }); + + it("rehydrates a room from disk in a fresh registry (restart simulation)", async () => { + const reg1 = newRegistry(); + const { roomCode, playerId } = await reg1.createRoom("Kira"); + const room1 = await reg1.getOrRehydrate(roomCode); + await room1!.appendChat(playerId, "ic", "persisted line", "c1"); + const seqAfterChat = room1!.record.seq; + + const reg2 = newRegistry(); // same dir, no in-memory state + const room2 = await reg2.getOrRehydrate(roomCode); + expect(room2).not.toBeNull(); + expect(room2!.players[0]!.name).toBe("Kira"); + expect(room2!.record.seq).toBe(seqAfterChat); + expect(room2!.buildSnapshot().transcript.at(-1)!.text).toBe("persisted line"); + }); + + it("returns null for an unknown room", async () => { + expect(await newRegistry().getOrRehydrate("ZZZZZZ")).toBeNull(); + }); + + it("sweepIdle evicts only zero-socket, idle rooms", async () => { + const reg = newRegistry({ IDLE_EVICT_MS: "1000" }); + const { roomCode } = await reg.createRoom("Kira"); + await reg.getOrRehydrate(roomCode); + expect(reg.size).toBe(1); + await reg.sweepIdle(Date.now() + 5000); // far future, no sockets + expect(reg.size).toBe(0); + // still rehydratable from disk afterward + expect(await reg.getOrRehydrate(roomCode)).not.toBeNull(); + }); +}); From 83a208971577ba6dbd423e9f95663ec37b73a943 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 16:26:48 +0800 Subject: [PATCH 11/15] feat(server): POST /api/rooms + healthz live-room count --- packages/server/src/app.ts | 7 +++- packages/server/src/http/rooms.ts | 18 ++++++++ packages/server/src/index.ts | 11 ++++- packages/server/test/healthz.test.ts | 17 +++++++- packages/server/test/rooms-route.test.ts | 52 ++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/http/rooms.ts create mode 100644 packages/server/test/rooms-route.test.ts diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 514fb07..64d79a7 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,8 +1,11 @@ import Fastify, { type FastifyInstance } from "fastify"; import type { Config } from "./config.js"; +import type { RoomRegistry } from "./rooms/registry.js"; +import { registerRoomRoutes } from "./http/rooms.js"; export interface AppDeps { config: Config; + registry: RoomRegistry; } export function buildApp(deps: AppDeps): FastifyInstance { @@ -11,9 +14,9 @@ export function buildApp(deps: AppDeps): FastifyInstance { app.get("/healthz", async () => ({ status: "ok", uptime: process.uptime(), - liveRooms: 0, + liveRooms: deps.registry.size, })); - void deps; // config consumed by routes/ws added in later tasks + registerRoomRoutes(app, deps.registry); return app; } diff --git a/packages/server/src/http/rooms.ts b/packages/server/src/http/rooms.ts new file mode 100644 index 0000000..eff28b1 --- /dev/null +++ b/packages/server/src/http/rooms.ts @@ -0,0 +1,18 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import type { RoomRegistry } from "../rooms/registry.js"; + +const CreateRoomBody = z.object({ + hostName: z.string().min(1).max(40), +}); + +export function registerRoomRoutes(app: FastifyInstance, registry: RoomRegistry): void { + app.post("/api/rooms", async (req, reply) => { + const parsed = CreateRoomBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ error: "invalid body", detail: parsed.error.message }); + } + const result = await registry.createRoom(parsed.data.hostName); + return reply.code(201).send(result); + }); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 51ea15a..4d0c563 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,12 +1,18 @@ import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; +import { RoomRegistry } from "./rooms/registry.js"; const config = loadConfig(process.env); -const app = buildApp({ config }); +const registry = new RoomRegistry({ config, dataDir: config.DATA_DIR }); +const app = buildApp({ config, registry }); + +const idleTimer = setInterval(() => { + void registry.sweepIdle(Date.now()); +}, 60_000); +idleTimer.unref(); async function main() { await app.listen({ port: config.PORT, host: "0.0.0.0" }); - // eslint-disable-next-line no-console console.log(`[gm] listening on :${config.PORT}`); } @@ -15,6 +21,7 @@ async function shutdown(signal: string) { if (shuttingDown) return; shuttingDown = true; console.log(`[gm] ${signal} received, draining`); + clearInterval(idleTimer); await app.close(); process.exit(0); } diff --git a/packages/server/test/healthz.test.ts b/packages/server/test/healthz.test.ts index e18b049..730f3fb 100644 --- a/packages/server/test/healthz.test.ts +++ b/packages/server/test/healthz.test.ts @@ -1,10 +1,23 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-health-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); describe("GET /healthz", () => { it("returns 200 with status ok and a numeric uptime", async () => { - const app = buildApp({ config: loadConfig({}) }); + const config = loadConfig({ DATA_DIR: dir }); + const app = buildApp({ config, registry: new RoomRegistry({ config, dataDir: dir }) }); const res = await app.inject({ method: "GET", url: "/healthz" }); expect(res.statusCode).toBe(200); const body = res.json(); diff --git a/packages/server/test/rooms-route.test.ts b/packages/server/test/rooms-route.test.ts new file mode 100644 index 0000000..440b1d2 --- /dev/null +++ b/packages/server/test/rooms-route.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; + +let dir: string; +async function appWithRegistry() { + const config = loadConfig({ DATA_DIR: dir }); + const registry = new RoomRegistry({ config, dataDir: dir }); + return { app: buildApp({ config, registry }), registry }; +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-route-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("POST /api/rooms", () => { + it("creates a room and returns code+token", async () => { + const { app } = await appWithRegistry(); + const res = await app.inject({ + method: "POST", + url: "/api/rooms", + payload: { hostName: "Kira" }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.roomCode).toMatch(/^[0-9A-Z]{6}$/); + expect(typeof body.token).toBe("string"); + await app.close(); + }); + + it("rejects a missing hostName with 400", async () => { + const { app } = await appWithRegistry(); + const res = await app.inject({ method: "POST", url: "/api/rooms", payload: {} }); + expect(res.statusCode).toBe(400); + await app.close(); + }); + + it("/healthz reports the live room count", async () => { + const { app } = await appWithRegistry(); + await app.inject({ method: "POST", url: "/api/rooms", payload: { hostName: "Kira" } }); + const res = await app.inject({ method: "GET", url: "/healthz" }); + expect(res.json().liveRooms).toBe(1); + await app.close(); + }); +}); From 5796e380e3f10be9b1a159c2b44f660b584d92b0 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 16:28:18 +0800 Subject: [PATCH 12/15] feat(server): WS envelope dispatch (join/chat/leave/close/ping) + heartbeat detector --- packages/server/src/ws/dispatch.ts | 154 ++++++++++++++++++++++++ packages/server/src/ws/heartbeat.ts | 41 +++++++ packages/server/test/dispatch.test.ts | 159 +++++++++++++++++++++++++ packages/server/test/heartbeat.test.ts | 50 ++++++++ 4 files changed, 404 insertions(+) create mode 100644 packages/server/src/ws/dispatch.ts create mode 100644 packages/server/src/ws/heartbeat.ts create mode 100644 packages/server/test/dispatch.test.ts create mode 100644 packages/server/test/heartbeat.test.ts diff --git a/packages/server/src/ws/dispatch.ts b/packages/server/src/ws/dispatch.ts new file mode 100644 index 0000000..779bbef --- /dev/null +++ b/packages/server/src/ws/dispatch.ts @@ -0,0 +1,154 @@ +import { + parseEnvelope, + parsePayload, + C2S_SCHEMAS, + PROTOCOL_VERSION, + makeEnvelope, + type RoomJoinReq, + type ChatSend, + type ErrorCode, +} from "@gm/protocol"; +import type { RoomRegistry } from "../rooms/registry.js"; +import type { Config } from "../config.js"; +import type { ClientSocket, Room } from "../rooms/room.js"; +import { nameTaken, findByToken } from "../identity/roster.js"; +import { newPlayerId, mintToken, hashToken } from "../identity/token.js"; +import { type PlayerRecord } from "../store/tableStore.js"; + +export interface Connection { + socket: ClientSocket; + playerId: string | null; + roomCode: string | null; +} + +interface Deps { + registry: RoomRegistry; + config: Config; +} + +function sendError(socket: ClientSocket, code: ErrorCode, message: string, refId: string | null): void { + socket.send(JSON.stringify(makeEnvelope({ type: "error", room: null, payload: { code, message, refId } }))); +} + +export async function handleEnvelope(deps: Deps, conn: Connection, raw: string): Promise { + const parsed = parseEnvelope(raw); + if (!parsed.ok) return sendError(conn.socket, "BAD_REQUEST", parsed.error, null); + const env = parsed.envelope; + + // Unknown types are silently ignored (additive-evolution rule). + if (!(env.type in C2S_SCHEMAS)) return; + + const payloadResult = parsePayload(env.type, env.payload, C2S_SCHEMAS); + if (!payloadResult.ok) return sendError(conn.socket, "BAD_REQUEST", payloadResult.error, env.id); + + switch (env.type) { + case "room.join": + return joinRoom(deps, conn, env.id, payloadResult.data as RoomJoinReq); + case "chat.send": + return chatSend(deps, conn, env.id, payloadResult.data as ChatSend); + case "room.leave": + return roomLeave(deps, conn); + case "room.close": + return roomClose(deps, conn, env.id); + case "system.ping": + conn.socket.send(JSON.stringify(makeEnvelope({ type: "system.pong", room: null, payload: {} }))); + return; + default: + return; + } +} + +async function joinRoom(deps: Deps, conn: Connection, refId: string, req: RoomJoinReq): Promise { + const room = await deps.registry.getOrRehydrate(req.roomCode); + if (!room) return sendError(conn.socket, "ROOM_NOT_FOUND", "no such room", refId); + if (room.record.phase === "ended") { + return sendError(conn.socket, "ROOM_NOT_FOUND", "this table has ended", refId); + } + + let seat: PlayerRecord | null = null; + let resumed = false; + let token = ""; + + if (req.playerToken) { + seat = findByToken(room.players, req.playerToken); + if (seat) { + resumed = true; + token = req.playerToken; + } + } + + if (!seat) { + if (nameTaken(room.players, req.displayName)) { + return sendError(conn.socket, "NAME_TAKEN", "that name is taken", refId); + } + if (room.players.length >= deps.config.ROOM_MAX_PLAYERS) { + return sendError(conn.socket, "ROOM_FULL", "this table is full", refId); + } + const now = Date.now(); + token = mintToken(); + seat = { + playerId: newPlayerId(), + name: req.displayName, + role: "player", + tokenHash: hashToken(token), + createdAt: now, + lastSeenAt: now, + }; + room.addPlayer(seat); + await room.persistPlayers(); + } + + // --- atomic block: no await between snapshot capture and subscribe --- + conn.playerId = seat.playerId; + conn.roomCode = room.code; + room.sendTo(conn.socket, "room.joined", { + roomCode: room.code, + playerId: seat.playerId, + playerToken: token, + resumed, + isHost: room.record.hostPlayerId === seat.playerId, + protocolVersion: PROTOCOL_VERSION, + }); + const snapshot = room.buildSnapshot(); + room.subscribe(seat.playerId, conn.socket); + room.sendTo(conn.socket, "room.snapshot", snapshot); + // --- end atomic block --- + + room.broadcast("presence.update", { players: room.publicPlayers() }); +} + +async function chatSend(deps: Deps, conn: Connection, refId: string, req: ChatSend): Promise { + const room = joinedRoom(deps, conn); + if (!room) return sendError(conn.socket, "FORBIDDEN", "join a room first", refId); + await room.appendChat(conn.playerId!, req.channel, req.text, refId); +} + +function roomLeave(deps: Deps, conn: Connection): void { + const room = joinedRoom(deps, conn); + if (!room) return; + room.unsubscribe(conn.playerId!, conn.socket); + room.broadcast("presence.update", { players: room.publicPlayers() }); +} + +async function roomClose(deps: Deps, conn: Connection, refId: string): Promise { + const room = joinedRoom(deps, conn); + if (!room) return sendError(conn.socket, "FORBIDDEN", "join a room first", refId); + if (room.record.hostPlayerId !== conn.playerId) { + return sendError(conn.socket, "FORBIDDEN", "host only", refId); + } + room.record.phase = "ended"; + room.broadcast("room.closed", { reason: "host ended the table" }); + await deps.registry.evict(room.code); +} + +export function handleClose(deps: Deps, conn: Connection): void { + const room = joinedRoom(deps, conn); + if (!room || !conn.playerId) return; + room.unsubscribe(conn.playerId, conn.socket); + room.broadcast("presence.update", { players: room.publicPlayers() }); +} + +function joinedRoom(deps: Deps, conn: Connection): Room | null { + if (!conn.roomCode || !conn.playerId) return null; + return deps.registry.peek(conn.roomCode); +} diff --git a/packages/server/src/ws/heartbeat.ts b/packages/server/src/ws/heartbeat.ts new file mode 100644 index 0000000..be61919 --- /dev/null +++ b/packages/server/src/ws/heartbeat.ts @@ -0,0 +1,41 @@ +export interface HeartbeatSocket { + ping(): void; + terminate(): void; +} + +interface Entry { + missed: number; + onDead: () => void; +} + +export class Heartbeat { + private tracked = new Map(); + + constructor(private readonly maxMissed: number) {} + + register(socket: HeartbeatSocket, onDead: () => void): void { + this.tracked.set(socket, { missed: 0, onDead }); + } + + pong(socket: HeartbeatSocket): void { + const e = this.tracked.get(socket); + if (e) e.missed = 0; + } + + unregister(socket: HeartbeatSocket): void { + this.tracked.delete(socket); + } + + tick(): void { + for (const [socket, e] of [...this.tracked.entries()]) { + if (e.missed >= this.maxMissed) { + this.tracked.delete(socket); + socket.terminate(); + e.onDead(); + continue; + } + e.missed += 1; + socket.ping(); + } + } +} diff --git a/packages/server/test/dispatch.test.ts b/packages/server/test/dispatch.test.ts new file mode 100644 index 0000000..139e809 --- /dev/null +++ b/packages/server/test/dispatch.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeEnvelope, parseEnvelope, type Envelope } from "@gm/protocol"; +import { handleEnvelope, handleClose, type Connection } from "../src/ws/dispatch.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; +import { loadConfig } from "../src/config.js"; +import type { ClientSocket } from "../src/rooms/room.js"; + +class FakeSocket implements ClientSocket { + sent: Envelope[] = []; + send(data: string) { + const r = parseEnvelope(data); + if (r.ok) this.sent.push(r.envelope); + } + ofType(type: string) { + return this.sent.filter((e) => e.type === type); + } + last(type: string) { + return this.ofType(type).at(-1); + } +} + +let dir: string; +let reg: RoomRegistry; +let deps: { registry: RoomRegistry; config: ReturnType }; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-disp-")); + const config = loadConfig({ DATA_DIR: dir, ROOM_MAX_PLAYERS: "2" }); + reg = new RoomRegistry({ config, dataDir: dir }); + deps = { registry: reg, config }; +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function conn(socket: ClientSocket): Connection { + return { socket, playerId: null, roomCode: null }; +} +function c2s(type: string, room: string | null, payload: unknown): string { + return JSON.stringify(makeEnvelope({ type, room, payload })); +} + +describe("handleEnvelope", () => { + it("host joins with token and gets joined+snapshot", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const s = new FakeSocket(); + const c = conn(s); + await handleEnvelope(deps, c, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + expect(s.last("room.joined")).toBeTruthy(); + expect((s.last("room.joined")!.payload as { resumed: boolean }).resumed).toBe(true); + expect(s.last("room.snapshot")).toBeTruthy(); + expect(c.playerId).not.toBeNull(); + }); + + it("unknown room → ROOM_NOT_FOUND", async () => { + const s = new FakeSocket(); + await handleEnvelope(deps, conn(s), c2s("room.join", "ZZZZZZ", { roomCode: "ZZZZZZ", displayName: "Bob" })); + expect((s.last("error")!.payload as { code: string }).code).toBe("ROOM_NOT_FOUND"); + }); + + it("a second new player joins, then a duplicate name is rejected", async () => { + const { roomCode } = await reg.createRoom("Kira"); + const s2 = new FakeSocket(); + await handleEnvelope(deps, conn(s2), c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + expect(s2.last("room.joined")).toBeTruthy(); + + const s3 = new FakeSocket(); + await handleEnvelope(deps, conn(s3), c2s("room.join", roomCode, { roomCode, displayName: "bob" })); + expect((s3.last("error")!.payload as { code: string }).code).toBe("NAME_TAKEN"); + }); + + it("ROOM_FULL once capacity is reached", async () => { + const { roomCode } = await reg.createRoom("Kira"); // 1/2 + const s2 = new FakeSocket(); + await handleEnvelope(deps, conn(s2), c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); // 2/2 + const s3 = new FakeSocket(); + await handleEnvelope(deps, conn(s3), c2s("room.join", roomCode, { roomCode, displayName: "Cy" })); + expect((s3.last("error")!.payload as { code: string }).code).toBe("ROOM_FULL"); + }); + + it("chat.send before join → FORBIDDEN; after join → broadcast to both", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const host = new FakeSocket(); + const hc = conn(host); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + + const guest = new FakeSocket(); + const gc = conn(guest); + await handleEnvelope(deps, gc, c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + + const bad = new FakeSocket(); + await handleEnvelope(deps, conn(bad), c2s("chat.send", roomCode, { text: "hi", channel: "ic" })); + expect((bad.last("error")!.payload as { code: string }).code).toBe("FORBIDDEN"); + + await handleEnvelope(deps, hc, c2s("chat.send", roomCode, { text: "hello table", channel: "ic" })); + expect(host.last("chat.message")).toBeTruthy(); + expect(guest.last("chat.message")).toBeTruthy(); + expect((guest.last("chat.message")!.payload as { text: string }).text).toBe("hello table"); + }); + + it("non-host close → FORBIDDEN; host close → room.closed + evicted", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const host = new FakeSocket(); + const hc = conn(host); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + const guest = new FakeSocket(); + const gc = conn(guest); + await handleEnvelope(deps, gc, c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + + await handleEnvelope(deps, gc, c2s("room.close", roomCode, {})); + expect((guest.last("error")!.payload as { code: string }).code).toBe("FORBIDDEN"); + + await handleEnvelope(deps, hc, c2s("room.close", roomCode, {})); + expect(host.last("room.closed")).toBeTruthy(); + expect(reg.size).toBe(0); + }); + + it("rejects joining a table the host has ended", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const hc = conn(new FakeSocket()); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + await handleEnvelope(deps, hc, c2s("room.close", roomCode, {})); // sets phase:ended, evicts (flush persists it) + + const late = new FakeSocket(); + // getOrRehydrate reloads the ended room from disk; the phase guard must reject the join. + await handleEnvelope(deps, conn(late), c2s("room.join", roomCode, { roomCode, displayName: "Late" })); + expect((late.last("error")!.payload as { code: string }).code).toBe("ROOM_NOT_FOUND"); + }); + + it("system.ping → system.pong", async () => { + const s = new FakeSocket(); + await handleEnvelope(deps, conn(s), c2s("system.ping", null, {})); + expect(s.last("system.pong")).toBeTruthy(); + }); + + it("unknown type is ignored (no error, no throwing)", async () => { + const s = new FakeSocket(); + await handleEnvelope(deps, conn(s), c2s("map.move", null, { x: 1 })); + expect(s.sent).toHaveLength(0); + }); + + it("handleClose marks the player offline and broadcasts presence", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const host = new FakeSocket(); + const hc = conn(host); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + const guest = new FakeSocket(); + const gc = conn(guest); + await handleEnvelope(deps, gc, c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + + handleClose(deps, gc); + const presence = host.last("presence.update")!.payload as { players: { displayName: string; online: boolean }[] }; + const bob = presence.players.find((p) => p.displayName === "Bob")!; + expect(bob.online).toBe(false); + }); +}); diff --git a/packages/server/test/heartbeat.test.ts b/packages/server/test/heartbeat.test.ts new file mode 100644 index 0000000..b204b11 --- /dev/null +++ b/packages/server/test/heartbeat.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { Heartbeat, type HeartbeatSocket } from "../src/ws/heartbeat.js"; + +class FakeWs implements HeartbeatSocket { + pings = 0; + terminated = false; + ping() { + this.pings++; + } + terminate() { + this.terminated = true; + } +} + +describe("Heartbeat", () => { + it("a socket that keeps ponging is never terminated", () => { + const hb = new Heartbeat(2); + const ws = new FakeWs(); + hb.register(ws, () => {}); + for (let i = 0; i < 10; i++) { + hb.tick(); + hb.pong(ws); + } + expect(ws.terminated).toBe(false); + }); + + it("a silent socket is terminated after maxMissed unanswered pings and fires onDead", () => { + const hb = new Heartbeat(2); + const ws = new FakeWs(); + let dead = false; + hb.register(ws, () => { + dead = true; + }); + hb.tick(); // missed 0->1, ping + hb.tick(); // missed 1->2, ping + expect(ws.terminated).toBe(false); + hb.tick(); // missed >= 2 -> terminate + expect(ws.terminated).toBe(true); + expect(dead).toBe(true); + }); + + it("unregister stops tracking", () => { + const hb = new Heartbeat(2); + const ws = new FakeWs(); + hb.register(ws, () => {}); + hb.unregister(ws); + hb.tick(); + expect(ws.pings).toBe(0); + }); +}); From 51cc5c368865437d809d8b15a7bd77b41c5dfba7 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 16:40:20 +0800 Subject: [PATCH 13/15] feat(server): Fastify WS gateway + M0 e2e acceptance (46 tests green) --- packages/server/src/app.ts | 5 + packages/server/src/config.ts | 2 +- packages/server/src/ws/gateway.ts | 59 +++++++++ packages/server/test/e2e.test.ts | 198 ++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/ws/gateway.ts create mode 100644 packages/server/test/e2e.test.ts diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 64d79a7..4fdcb48 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,7 +1,9 @@ import Fastify, { type FastifyInstance } from "fastify"; +import websocket from "@fastify/websocket"; import type { Config } from "./config.js"; import type { RoomRegistry } from "./rooms/registry.js"; import { registerRoomRoutes } from "./http/rooms.js"; +import { buildWsPlugin } from "./ws/gateway.js"; export interface AppDeps { config: Config; @@ -11,6 +13,9 @@ export interface AppDeps { export function buildApp(deps: AppDeps): FastifyInstance { const app = Fastify({ logger: false }); + void app.register(websocket); + void app.register(buildWsPlugin(deps)); + app.get("/healthz", async () => ({ status: "ok", uptime: process.uptime(), diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 70ed21b..ecf07b6 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -1,7 +1,7 @@ import { z } from "zod"; const ConfigSchema = z.object({ - PORT: z.coerce.number().int().positive().default(8787), + PORT: z.coerce.number().int().min(0).default(8787), DATA_DIR: z.string().default("./data"), WS_PING_MS: z.coerce.number().int().positive().default(30000), WS_MAX_MISSED_PONGS: z.coerce.number().int().positive().default(2), diff --git a/packages/server/src/ws/gateway.ts b/packages/server/src/ws/gateway.ts new file mode 100644 index 0000000..315a1b3 --- /dev/null +++ b/packages/server/src/ws/gateway.ts @@ -0,0 +1,59 @@ +import type { FastifyPluginCallback } from "fastify"; +import type { WebSocket } from "ws"; +import type { Config } from "../config.js"; +import type { RoomRegistry } from "../rooms/registry.js"; +import type { ClientSocket } from "../rooms/room.js"; +import { handleEnvelope, handleClose, type Connection } from "./dispatch.js"; +import { Heartbeat, type HeartbeatSocket } from "./heartbeat.js"; + +interface Deps { + registry: RoomRegistry; + config: Config; +} + +export function buildWsPlugin(deps: Deps): FastifyPluginCallback { + const heartbeat = new Heartbeat(deps.config.WS_MAX_MISSED_PONGS); + + const timer = setInterval(() => heartbeat.tick(), deps.config.WS_PING_MS); + timer.unref?.(); + + return (app, _opts, done) => { + app.get("/ws", { websocket: true }, (rawWs: WebSocket, _req) => { + const hbSocket: HeartbeatSocket = { + ping: () => rawWs.ping(), + terminate: () => rawWs.terminate(), + }; + const conn: Connection = { socket: toClientSocket(rawWs), playerId: null, roomCode: null }; + + heartbeat.register(hbSocket, () => { + handleClose(deps, conn); + }); + + rawWs.on("pong", () => heartbeat.pong(hbSocket)); + + rawWs.on("message", async (data: Buffer | ArrayBuffer | Buffer[]) => { + const raw = typeof data === "string" ? data : Buffer.from(data as ArrayBuffer).toString("utf8"); + try { + await handleEnvelope(deps, conn, raw); + } catch (err) { + app.log.error({ err }, "dispatch error"); + } + }); + + rawWs.on("close", () => { + heartbeat.unregister(hbSocket); + handleClose(deps, conn); + }); + }); + done(); + }; +} + +/** Adapt a ws.WebSocket to the minimal ClientSocket interface. */ +function toClientSocket(ws: WebSocket): ClientSocket { + return { + send(data: string) { + ws.send(data); + }, + }; +} diff --git a/packages/server/test/e2e.test.ts b/packages/server/test/e2e.test.ts new file mode 100644 index 0000000..a81ddac --- /dev/null +++ b/packages/server/test/e2e.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WebSocket } from "ws"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; +import { makeEnvelope, type Envelope } from "@gm/protocol"; + +let dir: string; +let app: ReturnType; +let baseUrl: string; +let wsUrl: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-e2e-")); + const config = loadConfig({ DATA_DIR: dir, PORT: "0" }); + const registry = new RoomRegistry({ config, dataDir: dir }); + app = buildApp({ config, registry }); + await app.listen({ port: 0, host: "127.0.0.1" }); + const address = app.server.address(); + const port = typeof address === "object" && address ? address.port : 0; + baseUrl = `http://127.0.0.1:${port}`; + wsUrl = `ws://127.0.0.1:${port}/ws`; +}); +afterEach(async () => { + await app.close(); + await rm(dir, { recursive: true, force: true }); +}); + +function wsConnect(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + ws.on("open", () => resolve(ws)); + ws.on("error", reject); + }); +} + +function nextMessage(ws: WebSocket, type: string, timeout = 2000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timeout waiting for ${type}`)), timeout); + ws.on("message", (data) => { + const env = JSON.parse(data.toString()) as Envelope; + if (env.type === type) { + clearTimeout(timer); + resolve(env); + } + }); + }); +} + +function send(ws: WebSocket, type: string, room: string | null, payload: unknown): void { + ws.send(JSON.stringify(makeEnvelope({ type, room, payload }))); +} + +async function drain(ws: WebSocket, type: string, count: number, timeout = 2000): Promise { + const collected: Envelope[] = []; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (collected.length >= count) resolve(collected); + else reject(new Error(`only got ${collected.length}/${count} ${type}`)); + }, timeout); + ws.on("message", (data) => { + const env = JSON.parse(data.toString()) as Envelope; + if (env.type === type) { + collected.push(env); + if (collected.length >= count) { + clearTimeout(timer); + resolve(collected); + } + } + }); + }); +} + +describe("M0 e2e acceptance", () => { + it("POST /api/rooms → WS join → chat broadcast → close", async () => { + // 1. Create room via HTTP + const createRes = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName: "Kira" }), + }); + expect(createRes.status).toBe(201); + const { roomCode, token: hostToken } = (await createRes.json()) as { roomCode: string; token: string }; + + // 2. Host joins via WS + const hostWs = await wsConnect(); + send(hostWs, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: hostToken }); + const hostJoined = await nextMessage(hostWs, "room.joined"); + expect((hostJoined.payload as { resumed: boolean }).resumed).toBe(true); + + // 3. Guest joins via WS + const guestWs = await wsConnect(); + send(guestWs, "room.join", roomCode, { roomCode, displayName: "Bob" }); + const guestJoined = await nextMessage(guestWs, "room.joined"); + expect((guestJoined.payload as { resumed: boolean }).resumed).toBe(false); + + // 4. Host sends chat → both receive chat.message + // Host will also receive its own presence.update for the guest join. + send(hostWs, "chat.send", roomCode, { text: "hello world", channel: "ic" }); + + const hostChat = await nextMessage(hostWs, "chat.message"); + const guestChat = await nextMessage(guestWs, "chat.message"); + expect((hostChat.payload as { text: string }).text).toBe("hello world"); + expect((guestChat.payload as { text: string }).text).toBe("hello world"); + + // 5. Host closes the room + send(hostWs, "room.close", roomCode, {}); + const closedMsg = await nextMessage(hostWs, "room.closed"); + expect(closedMsg.type).toBe("room.closed"); + const guestClosed = await nextMessage(guestWs, "room.closed"); + expect(guestClosed.type).toBe("room.closed"); + + // Give evict (async) a moment to complete after the broadcast + await new Promise((r) => setTimeout(r, 50)); + + // 6. /healthz shows 0 live rooms after eviction + const healthRes = await fetch(`${baseUrl}/healthz`); + const healthBody = (await healthRes.json()) as { liveRooms: number }; + expect(healthBody.liveRooms).toBe(0); + + hostWs.close(); + guestWs.close(); + }); + + it("guest cannot close the room", async () => { + const createRes = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName: "Kira" }), + }); + const { roomCode, token: hostToken } = (await createRes.json()) as { roomCode: string; token: string }; + + const hostWs = await wsConnect(); + send(hostWs, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: hostToken }); + await nextMessage(hostWs, "room.joined"); + + const guestWs = await wsConnect(); + send(guestWs, "room.join", roomCode, { roomCode, displayName: "Bob" }); + await nextMessage(guestWs, "room.joined"); + + send(guestWs, "room.close", roomCode, {}); + const err = await nextMessage(guestWs, "error"); + expect((err.payload as { code: string }).code).toBe("FORBIDDEN"); + + hostWs.close(); + guestWs.close(); + }); + + it("duplicate display name is rejected (case-insensitive)", async () => { + const createRes = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName: "Kira" }), + }); + const { roomCode, token: hostToken } = (await createRes.json()) as { roomCode: string; token: string }; + + const hostWs = await wsConnect(); + send(hostWs, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: hostToken }); + await nextMessage(hostWs, "room.joined"); + + const dupWs = await wsConnect(); + send(dupWs, "room.join", roomCode, { roomCode, displayName: "kira" }); // lowercase dup + const err = await nextMessage(dupWs, "error"); + expect((err.payload as { code: string }).code).toBe("NAME_TAKEN"); + + hostWs.close(); + dupWs.close(); + }); + + it("player token resume works after disconnect", async () => { + const createRes = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName: "Kira" }), + }); + const { roomCode, token: hostToken } = (await createRes.json()) as { roomCode: string; token: string }; + + // Host joins, then disconnects + const ws1 = await wsConnect(); + send(ws1, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: hostToken }); + await nextMessage(ws1, "room.joined"); + ws1.close(); + + // Wait a moment for close to process + await new Promise((r) => setTimeout(r, 100)); + + // Host reconnects with same token + const ws2 = await wsConnect(); + send(ws2, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: hostToken }); + const rejoined = await nextMessage(ws2, "room.joined"); + expect((rejoined.payload as { resumed: boolean }).resumed).toBe(true); + + ws2.close(); + }); +}); From 2d53e3ea48f283f9829bdd7894a9273fe6db89f0 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 16:42:25 +0800 Subject: [PATCH 14/15] feat(web): Vite + React + zustand store + WS/API clients + Lobby/RoomView UI --- packages/web/index.html | 12 ++ packages/web/package.json | 25 ++++ packages/web/src/api.ts | 14 +++ packages/web/src/components/Lobby.tsx | 165 ++++++++++++++++++++++++++ packages/web/src/main.tsx | 9 ++ packages/web/src/store.ts | 121 +++++++++++++++++++ packages/web/src/ws.ts | 53 +++++++++ packages/web/tsconfig.json | 15 +++ packages/web/vite.config.ts | 17 +++ 9 files changed, 431 insertions(+) create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/src/api.ts create mode 100644 packages/web/src/components/Lobby.tsx create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/store.ts create mode 100644 packages/web/src/ws.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..6ad92da --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + Game Master + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..8e92b7d --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gm/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@gm/protocol": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts new file mode 100644 index 0000000..e62e85d --- /dev/null +++ b/packages/web/src/api.ts @@ -0,0 +1,14 @@ +export interface RoomSummary { + roomCode: string; + hostName: string; +} + +export async function createRoom(hostName: string): Promise<{ roomCode: string; token: string }> { + const res = await fetch("/api/rooms", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName }), + }); + if (!res.ok) throw new Error(`createRoom failed: ${res.status}`); + return res.json(); +} diff --git a/packages/web/src/components/Lobby.tsx b/packages/web/src/components/Lobby.tsx new file mode 100644 index 0000000..cb5284f --- /dev/null +++ b/packages/web/src/components/Lobby.tsx @@ -0,0 +1,165 @@ +import { useState, useCallback, useRef } from "react"; +import { createRoom } from "../api"; +import { WsClient } from "../ws"; +import { useGameStore } from "../store"; + +export function Lobby() { + const [hostName, setHostName] = useState(""); + const [roomCode, setRoomCode] = useState(""); + const [joinName, setJoinName] = useState(""); + const wsRef = useRef(null); + + const store = useGameStore(); + const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`; + + const handleHost = useCallback(async () => { + if (!hostName.trim()) return; + store.setPhase("joining"); + try { + const { roomCode: code, token } = await createRoom(hostName.trim()); + const ws = new WsClient(); + wsRef.current = ws; + ws.onMessage(store.handleEnvelope); + ws.connect(wsUrl); + // Wait a tick for WS open, then join + setTimeout(() => { + ws.send("room.join", code, { + roomCode: code, + displayName: hostName.trim(), + playerToken: token, + }); + }, 200); + } catch (err) { + store.setError(String(err)); + } + }, [hostName, store]); + + const handleJoin = useCallback(() => { + if (!roomCode.trim() || !joinName.trim()) return; + store.setPhase("joining"); + const ws = new WsClient(); + wsRef.current = ws; + ws.onMessage(store.handleEnvelope); + ws.connect(wsUrl); + setTimeout(() => { + ws.send("room.join", roomCode.trim(), { + roomCode: roomCode.trim(), + displayName: joinName.trim(), + }); + }, 200); + }, [roomCode, joinName, store]); + + if (store.phase === "joined") { + return ; + } + + return ( +
+

Game Master

+ + {store.error && ( +
{store.error}
+ )} + +
+

Host a new table

+ setHostName(e.target.value)} + style={{ marginRight: 8 }} + /> + +
+ +
+

Join an existing table

+ setRoomCode(e.target.value)} + style={{ marginRight: 8, width: 100 }} + /> + setJoinName(e.target.value)} + style={{ marginRight: 8 }} + /> + +
+
+ ); +} + +function RoomView({ ws }: { ws: WsClient | null }) { + const store = useGameStore(); + const [text, setText] = useState(""); + + const handleSend = useCallback(() => { + if (!text.trim() || !ws) return; + ws.send("chat.send", store.roomCode, { text: text.trim(), channel: "ic" }); + setText(""); + }, [text, ws, store.roomCode]); + + const handleClose = useCallback(() => { + if (!ws) return; + ws.send("room.close", store.roomCode, {}); + }, [ws, store.roomCode]); + + return ( +
+
+

Table: {store.roomCode}

+ {store.isHost && ( + + )} +
+ +
+

Players ({store.players.length})

+
    + {store.players.map((p) => ( +
  • + {p.displayName} {p.role === "host" ? "(host)" : ""} {p.online ? "online" : "offline"} +
  • + ))} +
+
+ +
+

Chat

+
+ {store.chat.length === 0 && No messages yet.} + {store.chat.map((msg, i) => ( +
+ {msg.playerId === store.playerId ? "You" : msg.playerId}: {msg.text} +
+ ))} +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + style={{ width: "70%", marginRight: 8 }} + /> + +
+
+ ); +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..a0bf5dd --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Lobby } from "./components/Lobby"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/packages/web/src/store.ts b/packages/web/src/store.ts new file mode 100644 index 0000000..ec4df07 --- /dev/null +++ b/packages/web/src/store.ts @@ -0,0 +1,121 @@ +import { create } from "zustand"; +import type { Envelope } from "@gm/protocol"; + +export interface Player { + playerId: string; + displayName: string; + role: string; + online: boolean; +} + +export interface ChatMessage { + id: string; + playerId: string; + channel: string; + text: string; + ts: number; +} + +export type ConnectionPhase = "idle" | "joining" | "joined" | "error"; + +interface GameState { + phase: ConnectionPhase; + roomCode: string | null; + playerId: string | null; + playerToken: string | null; + isHost: boolean; + players: Player[]; + chat: ChatMessage[]; + error: string | null; + + setJoined: (data: { + roomCode: string; + playerId: string; + playerToken: string; + isHost: boolean; + }) => void; + setPlayers: (players: Player[]) => void; + addChat: (msg: ChatMessage) => void; + setError: (err: string | null) => void; + setPhase: (phase: ConnectionPhase) => void; + reset: () => void; + handleEnvelope: (env: Envelope) => void; +} + +export const useGameStore = create((set, get) => ({ + phase: "idle", + roomCode: null, + playerId: null, + playerToken: null, + isHost: false, + players: [], + chat: [], + error: null, + + setJoined: (data) => + set({ + phase: "joined", + roomCode: data.roomCode, + playerId: data.playerId, + playerToken: data.playerToken, + isHost: data.isHost, + error: null, + }), + + setPlayers: (players) => set({ players }), + addChat: (msg) => set((s) => ({ chat: [...s.chat, msg] })), + setError: (err) => set({ error: err, phase: err ? "error" : "idle" }), + setPhase: (phase) => set({ phase }), + reset: () => + set({ + phase: "idle", + roomCode: null, + playerId: null, + playerToken: null, + isHost: false, + players: [], + chat: [], + error: null, + }), + + handleEnvelope: (env) => { + const state = get(); + switch (env.type) { + case "room.joined": { + const p = env.payload as { + roomCode: string; + playerId: string; + playerToken: string; + isHost: boolean; + }; + state.setJoined(p); + break; + } + case "room.snapshot": { + const p = env.payload as { players: Player[]; chat: ChatMessage[] }; + set({ players: p.players ?? [], chat: p.chat ?? [] }); + break; + } + case "presence.update": { + const p = env.payload as { players: Player[] }; + state.setPlayers(p.players ?? []); + break; + } + case "chat.message": { + const p = env.payload as ChatMessage; + state.addChat(p); + break; + } + case "room.closed": { + const p = env.payload as { reason: string }; + state.setError(p.reason || "Room closed"); + break; + } + case "error": { + const p = env.payload as { code: string; message: string }; + state.setError(`${p.code}: ${p.message}`); + break; + } + } + }, +})); diff --git a/packages/web/src/ws.ts b/packages/web/src/ws.ts new file mode 100644 index 0000000..5a55df6 --- /dev/null +++ b/packages/web/src/ws.ts @@ -0,0 +1,53 @@ +import { makeEnvelope, parseEnvelope, type Envelope } from "@gm/protocol"; + +export type MessageHandler = (env: Envelope) => void; + +export class WsClient { + private ws: WebSocket | null = null; + private handlers = new Set(); + private reconnectTimer: ReturnType | null = null; + + connect(url: string): void { + this.ws = new WebSocket(url); + + this.ws.onopen = () => { + console.log("[ws] connected"); + }; + + this.ws.onmessage = (ev) => { + const raw = typeof ev.data === "string" ? ev.data : ""; + const result = parseEnvelope(raw); + if (result.ok) { + for (const h of this.handlers) { + h(result.envelope); + } + } + }; + + this.ws.onclose = () => { + console.log("[ws] disconnected"); + this.ws = null; + }; + + this.ws.onerror = () => { + console.error("[ws] error"); + }; + } + + send(type: string, room: string | null, payload: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(makeEnvelope({ type, room, payload }))); + } + } + + onMessage(handler: MessageHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + close(): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..c5ee679 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src", "test"] +} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..77024df --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:8787", + "/ws": { + target: "ws://127.0.0.1:8787", + ws: true, + }, + "/healthz": "http://127.0.0.1:8787", + }, + }, +}); From 4aaeaa1bfa8de0e62c7c0eebc92ee41dc30403b0 Mon Sep 17 00:00:00 2001 From: yichenhuang2000 Date: Thu, 2 Jul 2026 16:43:07 +0800 Subject: [PATCH 15/15] =?UTF-8?q?chore:=20M0=20gate=20=E2=80=94=2060=20tes?= =?UTF-8?q?ts=20green,=20typecheck=20pass,=20web=20build=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-25-m0-harness-scaffold.md | 3627 +++++++++++++++++ pnpm-lock.yaml | 778 ++++ 2 files changed, 4405 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-m0-harness-scaffold.md diff --git a/docs/superpowers/plans/2026-06-25-m0-harness-scaffold.md b/docs/superpowers/plans/2026-06-25-m0-harness-scaffold.md new file mode 100644 index 0000000..fd77251 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-m0-harness-scaffold.md @@ -0,0 +1,3627 @@ +# M0 Harness Scaffold 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:** Stand up the transport + identity + persistence skeleton of the self-built GM harness — a pnpm/TypeScript monorepo where two browsers join a room by code, see each other's chat, reconnect into restored state, and a force-killed socket leaves no ghost player. **No LLM, no game engine, no content migration.** + +**Architecture:** One Node process (Fastify) serves an HTTP API + a WebSocket endpoint + (in prod) the built SPA. A `@gm/protocol` package owns the zod wire contract imported by both server and client. The server is split into a transport layer (`http/`, `ws/`) and a transport-agnostic core (`rooms/`, `identity/`, `store/`). Room state lives in an in-memory **Room actor** per table, persisted to flat files (`room.json`, `players.json`, `transcript.jsonl`) at well-defined points and rehydrated on demand. A React + zustand web client reduces the WS event stream through a single `applyEvent` function. + +**Tech Stack:** Node 22, pnpm 10 workspaces, TypeScript (strict), zod@^4, Fastify + `@fastify/websocket` + `@fastify/static`, `ws` (via the plugin), React + Vite + Tailwind + zustand, vitest. ESM everywhere (`"type": "module"`). + +## Global Constraints + +These apply to **every** task. Copied from the design spec (`docs/superpowers/specs/2026-06-12-online-harness-design.md`); do not relitigate (spec §9). + +- **Node 22, pnpm workspaces, full ESM.** Every `package.json` has `"type": "module"`. No CommonJS. +- **`@gm/protocol` is the single source of truth for everything on the wire** (spec §3). Server and client import the same zod schemas. It is consumed **as TypeScript source** — it has **no build step** and its `exports` point at `src/index.ts`. `zod` is its **only** dependency. +- **The `engine`/core layer never imports `fastify` or `ws`.** `rooms/`, `identity/`, `store/` import only `@gm/protocol`, `zod`, and `node:*`. This rule is the portability guarantee — the transport is swappable. (In M0 there is no `engine/` yet; the same rule binds `rooms/`/`identity/`/`store/`.) +- **Two protocol evolution rules, enforced by construction:** (1) an envelope with an unknown `type` is silently ignored; (2) both sides tolerate unknown payload keys (zod `.strip()`, which is zod's default object behavior). Never throw on an unknown event type. +- **Recovery is snapshot-only** (spec §3.2, §9.2). There is no client-facing event replay, ever. `transcript.jsonl` is a record, never a catch-up source. The only recovery path — fresh join, reconnect, seq gap — is `room.join` → `room.joined` + `room.snapshot`; the client sets `lastSeq = snapshot.payload.seq` and re-renders from the snapshot. +- **Identity without accounts** (spec §5.2): a seat mints a `playerId` + a 192-bit token; `players.json` stores only the **sha256 hash** of the token. The client stores `{playerId, token}` in `localStorage` keyed by room code. `room.leave` does **not** sever the token↔seat binding (spec §9.13). +- **Persistence is flat files; the unit of durability is a well-defined write point.** `writeFileAtomic` = write tmp → rename. `*.json` is zod-validated and code-only; `*.md` (none in M0) is prose. `fsync` is deliberately omitted in v0. +- **Room code = table id = directory name.** 6 chars, Crockford-style alphabet excluding `0 O 1 I L U`, canonical uppercase. `mkdir` (non-recursive) is the atomic collision arbiter. Codes are never reused. +- **Reserved/unused protocol namespaces stay unschema'd** (`dice.*`, `gm.*`, `character.*`, `scene.*`, `module.*`, `map.*`, `media.*`, `combat.*`, `session.*`). M0 ships only the ~13 event types in Task 3. Everything else is M1+. +- **TDD, frequent commits.** Each task is red → green → commit. Run `pnpm -r typecheck` before each commit; it must pass. + +--- + +## File Structure + +Created across the tasks below (a path appears in the task that owns it): + +``` +game-master/ + pnpm-workspace.yaml # Task 1 + package.json # Task 1 (root scripts, private) + tsconfig.base.json # Task 1 + .env.example # Task 1 + .gitignore # Task 1 (append) + packages/ + protocol/ + package.json tsconfig.json vitest.config.ts + src/index.ts # Task 2/3 barrel + src/envelope.ts # Task 2 + src/events.ts # Task 3 + src/codes.ts # Task 3 + test/envelope.test.ts # Task 2 + test/events.test.ts # Task 3 + server/ + package.json tsconfig.json vitest.config.ts + src/index.ts # Task 4 (entry, listen, SIGTERM) + src/config.ts # Task 4 + src/app.ts # Task 4 (buildApp), Task 9/13 (routes/ws) + src/store/paths.ts # Task 5 + src/store/atomic.ts # Task 5 + src/store/tableStore.ts # Task 5 + src/identity/token.ts # Task 6 + src/identity/roster.ts # Task 6 + src/rooms/code.ts # Task 7 + src/rooms/room.ts # Task 7 + src/rooms/registry.ts # Task 8 + src/http/rooms.ts # Task 9 (POST /api/rooms, GET /healthz) + src/ws/heartbeat.ts # Task 11 + src/ws/dispatch.ts # Task 10 (handleEnvelope), Task 13 (wiring) + test/*.test.ts # one per task + web/ + package.json tsconfig.json vite.config.ts vitest.config.ts + tailwind.config.js postcss.config.js index.html + src/main.tsx src/App.tsx src/index.css # Task 14 + src/store.ts # Task 14 + src/wsClient.ts # Task 14 + src/api.ts # Task 14 + src/components/*.tsx # Task 14 + test/store.test.ts # Task 14 + data/ # gitignored runtime state (Task 1) +``` + +--- + +## Task 1: Monorepo scaffold + +**Files:** +- Create: `pnpm-workspace.yaml`, `package.json`, `tsconfig.base.json`, `.env.example` +- Modify: `.gitignore` (append) + +**Interfaces:** +- Consumes: nothing. +- Produces: a pnpm workspace whose member glob is `packages/*`; `tsconfig.base.json` with `strict: true`, `moduleResolution: "Bundler"`, `module: "ESNext"` (every package tsconfig extends it); root scripts `test`, `typecheck`, `build`, `dev`. + +- [ ] **Step 1: Create the workspace manifest** + +Create `pnpm-workspace.yaml`: + +```yaml +packages: + - "packages/*" +``` + +- [ ] **Step 2: Create the root package.json** + +Create `package.json`: + +```json +{ + "name": "game-master-harness", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "build": "pnpm -r build", + "dev": "concurrently -n server,web -c blue,green \"pnpm --filter @gm/server dev\" \"pnpm --filter @gm/web dev\"" + }, + "devDependencies": { + "concurrently": "^9.1.0", + "typescript": "^5.6.0" + } +} +``` + +- [ ] **Step 3: Create the base tsconfig** + +Create `tsconfig.base.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": [] + } +} +``` + +- [ ] **Step 4: Create .env.example** + +Create `.env.example`: + +```bash +# Server process config (zod-validated at boot; see packages/server/src/config.ts) +PORT=8787 +DATA_DIR=./data +WS_PING_MS=30000 +WS_MAX_MISSED_PONGS=2 +IDLE_EVICT_MS=900000 +ROOM_MAX_PLAYERS=6 +``` + +- [ ] **Step 5: Append runtime/build artifacts to .gitignore** + +Append to `.gitignore` (keep existing lines): + +``` +node_modules/ +data/ +dist/ +*.tsbuildinfo +``` + +- [ ] **Step 6: Install and verify the workspace resolves** + +Run: + +```bash +corepack pnpm install +``` + +Expected: completes without error; creates `node_modules/` and `pnpm-lock.yaml`. (No member packages exist yet, so `pnpm -r typecheck` is a no-op — that's fine; members arrive in Task 2.) + +- [ ] **Step 7: Commit** + +```bash +git add pnpm-workspace.yaml package.json tsconfig.base.json .env.example .gitignore pnpm-lock.yaml +git commit -m "chore: scaffold pnpm/typescript monorepo for online harness" +``` + +--- + +## Task 2: `@gm/protocol` — envelope + +**Files:** +- Create: `packages/protocol/package.json`, `packages/protocol/tsconfig.json`, `packages/protocol/vitest.config.ts`, `packages/protocol/src/envelope.ts`, `packages/protocol/src/index.ts` +- Test: `packages/protocol/test/envelope.test.ts` + +**Interfaces:** +- Consumes: zod. +- Produces: + - `PROTOCOL_VERSION: 1` + - `Envelope` (zod schema) and `type Envelope` + - `makeEnvelope(input: { type: string; room: string | null; payload: unknown; id?: string; seq?: number; ts?: number }): Envelope` + - `parseEnvelope(raw: string): { ok: true; envelope: Envelope } | { ok: false; error: string }` + +- [ ] **Step 1: Create the package manifest** + +Create `packages/protocol/package.json`: + +```json +{ + "name": "@gm/protocol", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "vitest": "^2.1.0" + } +} +``` + +- [ ] **Step 2: Create the package tsconfig** + +Create `packages/protocol/tsconfig.json`. The `DOM` lib provides the ambient type for the WebCrypto global `crypto` (used by `makeEnvelope`'s `crypto.randomUUID()`) **without** pulling in `@types/node` — keeping zod the package's only dependency and the package browser-safe. (Verified: with `lib:["ES2023"]` alone and `types:[]`, `tsc` fails `TS2304: Cannot find name 'crypto'`; adding `DOM` makes it clean.) + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM"] + }, + "include": ["src", "test"] +} +``` + +- [ ] **Step 3: Create the vitest config** + +Create `packages/protocol/vitest.config.ts`: + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { environment: "node" }, +}); +``` + +- [ ] **Step 4: Write the failing test** + +Create `packages/protocol/test/envelope.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { Envelope, makeEnvelope, parseEnvelope, PROTOCOL_VERSION } from "../src/index.js"; + +describe("envelope", () => { + it("makeEnvelope fills defaults and validates against the schema", () => { + const env = makeEnvelope({ type: "chat.message", room: "ABC234", payload: { text: "hi" } }); + expect(env.v).toBe(1); + expect(typeof env.id).toBe("string"); + expect(env.id.length).toBeGreaterThan(0); + expect(typeof env.ts).toBe("number"); + expect(env.seq).toBeUndefined(); + expect(() => Envelope.parse(env)).not.toThrow(); + }); + + it("makeEnvelope includes seq only when provided", () => { + expect(makeEnvelope({ type: "x", room: "R", payload: {} }).seq).toBeUndefined(); + expect(makeEnvelope({ type: "x", room: "R", payload: {}, seq: 7 }).seq).toBe(7); + }); + + it("parseEnvelope accepts a valid wire string", () => { + const wire = JSON.stringify(makeEnvelope({ type: "room.join", room: null, payload: {} })); + const res = parseEnvelope(wire); + expect(res.ok).toBe(true); + if (res.ok) expect(res.envelope.type).toBe("room.join"); + }); + + it("parseEnvelope rejects malformed JSON", () => { + const res = parseEnvelope("{not json"); + expect(res.ok).toBe(false); + }); + + it("parseEnvelope rejects a wrong protocol version", () => { + const bad = JSON.stringify({ ...makeEnvelope({ type: "x", room: null, payload: {} }), v: 2 }); + expect(parseEnvelope(bad).ok).toBe(false); + }); + + it("PROTOCOL_VERSION is 1", () => { + expect(PROTOCOL_VERSION).toBe(1); + }); +}); +``` + +- [ ] **Step 5: Run the test, verify it fails** + +Run: `corepack pnpm install && corepack pnpm --filter @gm/protocol test` + +The `install` is required here: Task 1 created no member packages, so this is the first install that materializes `@gm/protocol`'s deps. A subagent shell is non-TTY, where a stale-lockfile auto-install (triggered by the new `package.json`) would otherwise abort with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. + +Expected: FAIL — cannot resolve `../src/index.js` (module not found). + +- [ ] **Step 6: Implement the envelope** + +Create `packages/protocol/src/envelope.ts`: + +```ts +import { z } from "zod"; + +export const PROTOCOL_VERSION = 1 as const; + +export const Envelope = z.object({ + v: z.literal(1), + id: z.string().min(1), + type: z.string().min(1), + room: z.string().nullable(), + seq: z.number().int().positive().optional(), + ts: z.number().int(), + payload: z.unknown(), +}); + +export type Envelope = z.infer; + +export function makeEnvelope(input: { + type: string; + room: string | null; + payload: unknown; + id?: string; + seq?: number; + ts?: number; +}): Envelope { + const env: Envelope = { + v: 1, + id: input.id ?? crypto.randomUUID(), + type: input.type, + room: input.room, + ts: input.ts ?? Date.now(), + payload: input.payload, + }; + if (input.seq !== undefined) env.seq = input.seq; + return env; +} + +export function parseEnvelope( + raw: string, +): { ok: true; envelope: Envelope } | { ok: false; error: string } { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return { ok: false, error: "invalid JSON" }; + } + const result = Envelope.safeParse(json); + if (!result.success) return { ok: false, error: result.error.message }; + return { ok: true, envelope: result.data }; +} +``` + +- [ ] **Step 7: Create the barrel** + +Create `packages/protocol/src/index.ts`: + +```ts +export * from "./envelope.js"; +``` + +- [ ] **Step 8: Run the test, verify it passes** + +Run: `corepack pnpm --filter @gm/protocol test` +Expected: PASS (6 tests). + +- [ ] **Step 9: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/protocol typecheck +git add packages/protocol pnpm-lock.yaml +git commit -m "feat(protocol): envelope schema + makeEnvelope/parseEnvelope" +``` + +--- + +## Task 3: `@gm/protocol` — M0 event catalogue + error codes + +**Files:** +- Create: `packages/protocol/src/codes.ts`, `packages/protocol/src/events.ts` +- Modify: `packages/protocol/src/index.ts` +- Test: `packages/protocol/test/events.test.ts` + +**Interfaces:** +- Consumes: `Envelope` (Task 2). +- Produces (exact names used by server Tasks 9–13 and web Task 14): + - Error codes: `ERROR_CODES` (readonly string tuple) and `type ErrorCode`. + - Shared payload types: `PlayerPublic`, `ChatMessage`, `RoomSnapshot`, `RoomJoined`, `RoomClosed`, `Presence`, `ErrorPayload` (zod schemas + inferred types). + - C2S request schemas: `RoomJoinReq`, `ChatSend`, `RoomLeave`, `RoomClose`, `SystemPing`. + - `C2S_SCHEMAS: Record` and `S2C_SCHEMAS: Record` keyed by event `type`. + - `parsePayload(type: string, payload: unknown, table): { ok: true; data: unknown } | { ok: false; error: string }` where `table` is `C2S_SCHEMAS` or `S2C_SCHEMAS`. Returns `{ ok: false, error: "unknown type" }` for a type not in the table (callers treat that as ignore-this-event, never throw). + +- [ ] **Step 1: Write error codes** + +Create `packages/protocol/src/codes.ts`: + +```ts +export const ERROR_CODES = [ + "BAD_REQUEST", + "ROOM_NOT_FOUND", + "ROOM_FULL", + "NAME_TAKEN", + "INVALID_TOKEN", + "FORBIDDEN", + "INTERNAL", +] as const; + +export type ErrorCode = (typeof ERROR_CODES)[number]; +``` + +- [ ] **Step 2: Write the failing test** + +Create `packages/protocol/test/events.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + C2S_SCHEMAS, + S2C_SCHEMAS, + parsePayload, + ChatSend, + RoomJoinReq, +} from "../src/index.js"; + +describe("event catalogue", () => { + it("accepts a valid chat.send", () => { + expect(ChatSend.safeParse({ text: "hello", channel: "ic" }).success).toBe(true); + }); + + it("rejects chat.send over 2000 chars", () => { + expect(ChatSend.safeParse({ text: "x".repeat(2001), channel: "ic" }).success).toBe(false); + }); + + it("rejects chat.send with a bad channel", () => { + expect(ChatSend.safeParse({ text: "hi", channel: "shout" }).success).toBe(false); + }); + + it("room.join token is optional", () => { + expect(RoomJoinReq.safeParse({ roomCode: "ABC234", displayName: "Kira" }).success).toBe(true); + expect( + RoomJoinReq.safeParse({ roomCode: "ABC234", displayName: "Kira", playerToken: "t" }).success, + ).toBe(true); + }); + + it("parsePayload validates a known type", () => { + const r = parsePayload("chat.send", { text: "hi", channel: "ooc" }, C2S_SCHEMAS); + expect(r.ok).toBe(true); + }); + + it("parsePayload reports unknown types without throwing", () => { + const r = parsePayload("map.move", { x: 1 }, C2S_SCHEMAS); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("unknown type"); + }); + + it("every M0 c2s and s2c type has a schema", () => { + expect(Object.keys(C2S_SCHEMAS).sort()).toEqual( + ["chat.send", "room.close", "room.join", "room.leave", "system.ping"].sort(), + ); + expect(Object.keys(S2C_SCHEMAS).sort()).toEqual( + [ + "chat.message", + "error", + "presence.update", + "room.closed", + "room.joined", + "room.snapshot", + "system.pong", + ].sort(), + ); + }); + + it("object schemas strip unknown keys (additive-evolution rule)", () => { + const parsed = ChatSend.parse({ text: "hi", channel: "ic", futureField: 42 }); + expect("futureField" in parsed).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run the test, verify it fails** + +Run: `corepack pnpm --filter @gm/protocol test` +Expected: FAIL — cannot resolve `C2S_SCHEMAS` etc. + +- [ ] **Step 4: Implement the event catalogue** + +Create `packages/protocol/src/events.ts`: + +```ts +import { z } from "zod"; +import { ERROR_CODES } from "./codes.js"; + +export const Channel = z.enum(["ic", "ooc"]); +export type Channel = z.infer; + +export const PlayerPublic = z.object({ + playerId: z.string(), + displayName: z.string(), + role: z.enum(["host", "player"]), + online: z.boolean(), +}); +export type PlayerPublic = z.infer; + +export const ChatMessage = z.object({ + messageId: z.string(), + clientId: z.string(), + playerId: z.string(), + displayName: z.string(), + channel: Channel, + characterName: z.string().nullable(), + text: z.string(), +}); +export type ChatMessage = z.infer; + +export const RoomSnapshot = z.object({ + seq: z.number().int().nonnegative(), + roomName: z.string(), + players: z.array(PlayerPublic), + scene: z.null(), + characters: z.array(z.unknown()), + pendingRollRequests: z.array(z.unknown()), + transcript: z.array(ChatMessage), + activeStream: z.null(), + gmStatus: z.literal("idle"), +}); +export type RoomSnapshot = z.infer; + +export const RoomJoined = z.object({ + roomCode: z.string(), + playerId: z.string(), + playerToken: z.string(), + resumed: z.boolean(), + isHost: z.boolean(), + protocolVersion: z.number().int(), +}); +export type RoomJoined = z.infer; + +export const RoomClosed = z.object({ reason: z.string() }); +export type RoomClosed = z.infer; + +export const Presence = z.object({ players: z.array(PlayerPublic) }); +export type Presence = z.infer; + +export const ErrorPayload = z.object({ + code: z.enum(ERROR_CODES), + message: z.string(), + refId: z.string().nullable(), +}); +export type ErrorPayload = z.infer; + +// ---- client → server requests ---- +export const RoomJoinReq = z.object({ + roomCode: z.string().min(1), + displayName: z.string().min(1).max(40), + playerToken: z.string().optional(), +}); +export type RoomJoinReq = z.infer; + +export const ChatSend = z.object({ + text: z.string().min(1).max(2000), + channel: Channel, +}); +export type ChatSend = z.infer; + +export const RoomLeave = z.object({}); +export const RoomClose = z.object({}); +export const SystemPing = z.object({}); + +export const C2S_SCHEMAS: Record = { + "room.join": RoomJoinReq, + "room.leave": RoomLeave, + "room.close": RoomClose, + "chat.send": ChatSend, + "system.ping": SystemPing, +}; + +export const S2C_SCHEMAS: Record = { + "room.joined": RoomJoined, + "room.snapshot": RoomSnapshot, + "room.closed": RoomClosed, + "presence.update": Presence, + "chat.message": ChatMessage, + "system.pong": z.object({}), + error: ErrorPayload, +}; + +export function parsePayload( + type: string, + payload: unknown, + table: Record, +): { ok: true; data: unknown } | { ok: false; error: string } { + const schema = table[type]; + if (!schema) return { ok: false, error: "unknown type" }; + const result = schema.safeParse(payload); + if (!result.success) return { ok: false, error: result.error.message }; + return { ok: true, data: result.data }; +} +``` + +- [ ] **Step 5: Update the barrel** + +Replace `packages/protocol/src/index.ts` with: + +```ts +export * from "./envelope.js"; +export * from "./codes.js"; +export * from "./events.js"; +``` + +- [ ] **Step 6: Run the test, verify it passes** + +Run: `corepack pnpm --filter @gm/protocol test` +Expected: PASS (all event tests + the Task 2 envelope tests). + +- [ ] **Step 7: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/protocol typecheck +git add packages/protocol +git commit -m "feat(protocol): M0 event catalogue, payload schemas, error codes" +``` + +--- + +## Task 4: `@gm/server` — config, app skeleton, /healthz, entry + +**Files:** +- Create: `packages/server/package.json`, `packages/server/tsconfig.json`, `packages/server/vitest.config.ts`, `packages/server/src/config.ts`, `packages/server/src/app.ts`, `packages/server/src/index.ts` +- Test: `packages/server/test/config.test.ts`, `packages/server/test/healthz.test.ts` + +**Interfaces:** +- Consumes: `@gm/protocol`. +- Produces: + - `type Config` and `loadConfig(env: NodeJS.ProcessEnv): Config` with fields `PORT, DATA_DIR, WS_PING_MS, WS_MAX_MISSED_PONGS, IDLE_EVICT_MS, ROOM_MAX_PLAYERS`. + - `buildApp(deps: AppDeps): FastifyInstance` where `interface AppDeps { config: Config; registry: RoomRegistry }`. **In Task 4 `registry` is typed as `unknown` via a placeholder**; Task 8 defines `RoomRegistry` and Task 9/13 tighten the type. To keep Task 4 self-contained, `AppDeps` here is `{ config: Config }` only; Task 9 widens it. + - `GET /healthz` → `200 { status: "ok"; uptime: number; liveRooms: number }` (in Task 4, `liveRooms` is `0`; Task 9 wires the real count). + +- [ ] **Step 1: Create the package manifest** + +Create `packages/server/package.json`: + +```json +{ + "name": "@gm/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --out-dir dist", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@fastify/static": "^8.0.0", + "@fastify/websocket": "^11.0.0", + "@gm/protocol": "workspace:*", + "fastify": "^5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "tsup": "^8.3.0", + "tsx": "^4.19.0", + "vitest": "^2.1.0", + "ws": "^8.18.0" + } +} +``` + +> Note: `ws` is a transitive dependency of `@fastify/websocket`; it is listed in `devDependencies` here only so tests can open a real client socket (Task 12) and import `ws` types. **Dev runs under `tsx watch`, not bare `node`** — Node 22's native type-stripping does NOT remap `.js` import specifiers to sibling `.ts` files (verified: `node src/index.ts` importing `./app.js` throws `ERR_MODULE_NOT_FOUND`), and the server is multi-file. `tsx` resolves `.js`→`.ts`. Prod is unaffected: `tsup` emits real `.js` and `start` runs `dist/index.js`. M0 needs no `--env-file` — `config.ts` provides a default for every var; `--env-file` returns in M1 when real secrets (LLM keys) exist. + +- [ ] **Step 2: Create the package tsconfig** + +Create `packages/server/tsconfig.json`: + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test"] +} +``` + +- [ ] **Step 3: Create the vitest config** + +Create `packages/server/vitest.config.ts`. **The `server.deps.inline` line is load-bearing**: `@gm/protocol` resolves to raw `.ts` source, so vitest must transform it instead of treating it as an external prebuilt dependency. + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + server: { deps: { inline: [/@gm\/protocol/] } }, + }, +}); +``` + +- [ ] **Step 4: Write the failing config test** + +Create `packages/server/test/config.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + it("applies defaults on empty env", () => { + const c = loadConfig({}); + expect(c.PORT).toBe(8787); + expect(c.DATA_DIR).toBe("./data"); + expect(c.WS_PING_MS).toBe(30000); + expect(c.WS_MAX_MISSED_PONGS).toBe(2); + expect(c.ROOM_MAX_PLAYERS).toBe(6); + }); + + it("coerces numeric strings", () => { + expect(loadConfig({ PORT: "9000" }).PORT).toBe(9000); + }); + + it("rejects a non-numeric PORT", () => { + expect(() => loadConfig({ PORT: "notaport" })).toThrow(); + }); +}); +``` + +- [ ] **Step 5: Write the failing healthz test** + +Create `packages/server/test/healthz.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +describe("GET /healthz", () => { + it("returns 200 with status ok and a numeric uptime", async () => { + const app = buildApp({ config: loadConfig({}) }); + const res = await app.inject({ method: "GET", url: "/healthz" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.status).toBe("ok"); + expect(typeof body.uptime).toBe("number"); + expect(typeof body.liveRooms).toBe("number"); + await app.close(); + }); +}); +``` + +- [ ] **Step 6: Run both tests, verify they fail** + +Run: `corepack pnpm install && corepack pnpm --filter @gm/server test` + +The `install` materializes the new server deps before the first run; without it a non-TTY subagent shell aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. + +Expected: FAIL — cannot resolve `../src/config.js` / `../src/app.js`. + +- [ ] **Step 7: Implement config** + +Create `packages/server/src/config.ts`: + +```ts +import { z } from "zod"; + +const ConfigSchema = z.object({ + PORT: z.coerce.number().int().positive().default(8787), + DATA_DIR: z.string().default("./data"), + WS_PING_MS: z.coerce.number().int().positive().default(30000), + WS_MAX_MISSED_PONGS: z.coerce.number().int().positive().default(2), + IDLE_EVICT_MS: z.coerce.number().int().positive().default(900000), + ROOM_MAX_PLAYERS: z.coerce.number().int().positive().default(6), +}); + +export type Config = z.infer; + +export function loadConfig(env: NodeJS.ProcessEnv): Config { + return ConfigSchema.parse(env); +} +``` + +- [ ] **Step 8: Implement the app skeleton** + +Create `packages/server/src/app.ts`: + +```ts +import Fastify, { type FastifyInstance } from "fastify"; +import type { Config } from "./config.js"; + +export interface AppDeps { + config: Config; +} + +export function buildApp(deps: AppDeps): FastifyInstance { + const app = Fastify({ logger: false }); + + app.get("/healthz", async () => ({ + status: "ok", + uptime: process.uptime(), + liveRooms: 0, + })); + + void deps; // config consumed by routes/ws added in later tasks + return app; +} +``` + +- [ ] **Step 9: Implement the process entry** + +Create `packages/server/src/index.ts`: + +```ts +import { buildApp } from "./app.js"; +import { loadConfig } from "./config.js"; + +const config = loadConfig(process.env); +const app = buildApp({ config }); + +async function main() { + await app.listen({ port: config.PORT, host: "0.0.0.0" }); + // eslint-disable-next-line no-console + console.log(`[gm] listening on :${config.PORT}`); +} + +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[gm] ${signal} received, draining`); + await app.close(); + process.exit(0); +} +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 10: Run both tests, verify they pass** + +Run: `corepack pnpm --filter @gm/server test` +Expected: PASS (config 3 + healthz 1). + +- [ ] **Step 11: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server pnpm-lock.yaml +git commit -m "feat(server): config, app skeleton, /healthz, entry with SIGTERM drain" +``` + +--- + +## Task 5: `@gm/server` — atomic store + table file I/O + +**Files:** +- Create: `packages/server/src/store/paths.ts`, `packages/server/src/store/atomic.ts`, `packages/server/src/store/tableStore.ts` +- Test: `packages/server/test/store.test.ts` + +**Interfaces:** +- Consumes: `node:fs/promises`, `node:path`, `node:crypto`, zod. +- Produces: + - `paths.ts`: `tableDir(dataDir, code)`, `roomJsonPath(dataDir, code)`, `playersJsonPath(dataDir, code)`, `transcriptPath(dataDir, code)` — all `string`. + - `atomic.ts`: `writeFileAtomic(path: string, data: string): Promise`. + - `tableStore.ts`: + - zod schemas + types `RoomRecord` (`{ code, phase: "active"|"ended", hostPlayerId, createdAt, lastActiveAt, seq, schemaVersion: 1 }`) and `PlayerRecord` (`{ playerId, name, role: "host"|"player", tokenHash, createdAt, lastSeenAt }`). + - `saveRoomRecord(dataDir, rec): Promise`, `loadRoomRecord(dataDir, code): Promise` (null when the dir/file is absent). + - `savePlayers(dataDir, code, players): Promise`, `loadPlayers(dataDir, code): Promise` (`[]` when absent). + - `appendTranscript(dataDir, code, msg: ChatMessage): Promise` (one JSON line), `tailTranscript(dataDir, code, n): Promise` (last `n`, `[]` when absent). + +- [ ] **Step 1: Write the failing test** + +Create `packages/server/test/store.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + saveRoomRecord, + loadRoomRecord, + savePlayers, + loadPlayers, + appendTranscript, + tailTranscript, + type RoomRecord, + type PlayerRecord, +} from "../src/store/tableStore.js"; +import type { ChatMessage } from "@gm/protocol"; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-store-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const rec: RoomRecord = { + code: "ABC234", + phase: "active", + hostPlayerId: "p1", + createdAt: 1, + lastActiveAt: 1, + seq: 0, + schemaVersion: 1, +}; + +function chat(messageId: string, text: string): ChatMessage { + return { + messageId, + clientId: "c", + playerId: "p1", + displayName: "Kira", + channel: "ic", + characterName: null, + text, + }; +} + +describe("tableStore", () => { + it("round-trips a room record", async () => { + await saveRoomRecord(dir, rec); + expect(await loadRoomRecord(dir, "ABC234")).toEqual(rec); + }); + + it("returns null for an absent room", async () => { + expect(await loadRoomRecord(dir, "ZZZZZZ")).toBeNull(); + }); + + it("round-trips the player roster", async () => { + const players: PlayerRecord[] = [ + { playerId: "p1", name: "Kira", role: "host", tokenHash: "h", createdAt: 1, lastSeenAt: 1 }, + ]; + await savePlayers(dir, "ABC234", players); + expect(await loadPlayers(dir, "ABC234")).toEqual(players); + }); + + it("returns [] for an absent roster", async () => { + expect(await loadPlayers(dir, "ZZZZZZ")).toEqual([]); + }); + + it("tails only the last n transcript lines", async () => { + for (let i = 0; i < 5; i++) await appendTranscript(dir, "ABC234", chat(`m${i}`, `line ${i}`)); + const tail = await tailTranscript(dir, "ABC234", 2); + expect(tail.map((m) => m.messageId)).toEqual(["m3", "m4"]); + }); + + it("returns [] tailing an absent transcript", async () => { + expect(await tailTranscript(dir, "ZZZZZZ", 10)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `corepack pnpm --filter @gm/server test store` +Expected: FAIL — cannot resolve `../src/store/tableStore.js`. + +- [ ] **Step 3: Implement paths** + +Create `packages/server/src/store/paths.ts`: + +```ts +import { join } from "node:path"; + +export function tableDir(dataDir: string, code: string): string { + return join(dataDir, "tables", code); +} +export function roomJsonPath(dataDir: string, code: string): string { + return join(tableDir(dataDir, code), "room.json"); +} +export function playersJsonPath(dataDir: string, code: string): string { + return join(tableDir(dataDir, code), "players.json"); +} +export function transcriptPath(dataDir: string, code: string): string { + return join(tableDir(dataDir, code), "transcript.jsonl"); +} +``` + +- [ ] **Step 4: Implement atomic write** + +Create `packages/server/src/store/atomic.ts`: + +```ts +import { writeFile, rename, mkdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname } from "node:path"; + +export async function writeFileAtomic(path: string, data: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp-${randomUUID()}`; + await writeFile(tmp, data, "utf8"); + await rename(tmp, path); +} +``` + +- [ ] **Step 5: Implement the table store** + +Create `packages/server/src/store/tableStore.ts`: + +```ts +import { readFile, appendFile, mkdir } from "node:fs/promises"; +import { z } from "zod"; +import { ChatMessage } from "@gm/protocol"; +import { writeFileAtomic } from "./atomic.js"; +import { roomJsonPath, playersJsonPath, transcriptPath, tableDir } from "./paths.js"; + +export const RoomRecord = z.object({ + code: z.string(), + phase: z.enum(["active", "ended"]), + hostPlayerId: z.string(), + createdAt: z.number().int(), + lastActiveAt: z.number().int(), + seq: z.number().int().nonnegative(), + schemaVersion: z.literal(1), +}); +export type RoomRecord = z.infer; + +export const PlayerRecord = z.object({ + playerId: z.string(), + name: z.string(), + role: z.enum(["host", "player"]), + tokenHash: z.string(), + createdAt: z.number().int(), + lastSeenAt: z.number().int(), +}); +export type PlayerRecord = z.infer; + +async function readJsonOrNull(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} + +export async function saveRoomRecord(dataDir: string, rec: RoomRecord): Promise { + await writeFileAtomic(roomJsonPath(dataDir, rec.code), JSON.stringify(rec, null, 2)); +} + +export async function loadRoomRecord(dataDir: string, code: string): Promise { + const raw = await readJsonOrNull(roomJsonPath(dataDir, code)); + if (raw === null) return null; + return RoomRecord.parse(raw); +} + +export async function savePlayers( + dataDir: string, + code: string, + players: PlayerRecord[], +): Promise { + await writeFileAtomic(playersJsonPath(dataDir, code), JSON.stringify(players, null, 2)); +} + +export async function loadPlayers(dataDir: string, code: string): Promise { + const raw = await readJsonOrNull(playersJsonPath(dataDir, code)); + if (raw === null) return []; + return z.array(PlayerRecord).parse(raw); +} + +export async function appendTranscript( + dataDir: string, + code: string, + msg: ChatMessage, +): Promise { + await mkdir(tableDir(dataDir, code), { recursive: true }); + await appendFile(transcriptPath(dataDir, code), JSON.stringify(msg) + "\n", "utf8"); +} + +export async function tailTranscript( + dataDir: string, + code: string, + n: number, +): Promise { + let text: string; + try { + text = await readFile(transcriptPath(dataDir, code), "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + throw err; + } + const lines = text.split("\n").filter((l) => l.length > 0); + return lines.slice(-n).map((l) => ChatMessage.parse(JSON.parse(l))); +} +``` + +- [ ] **Step 6: Run the test, verify it passes** + +Run: `corepack pnpm --filter @gm/server test store` +Expected: PASS (6 tests). + +- [ ] **Step 7: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): atomic writes + table file store (room/players/transcript)" +``` + +--- + +## Task 6: `@gm/server` — identity (token + roster) + +**Files:** +- Create: `packages/server/src/identity/token.ts`, `packages/server/src/identity/roster.ts` +- Test: `packages/server/test/identity.test.ts` + +**Interfaces:** +- Consumes: `node:crypto`, `PlayerRecord` (Task 5). +- Produces: + - `token.ts`: `newPlayerId(): string` (uuid), `mintToken(): string` (192-bit base64url), `hashToken(token): string` (sha256 hex), `verifyToken(token, hash): boolean` (constant-time). + - `roster.ts`: + - `nameTaken(players: PlayerRecord[], name: string): boolean` (case-insensitive). + - `findByToken(players: PlayerRecord[], token: string): PlayerRecord | null`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/server/test/identity.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { newPlayerId, mintToken, hashToken, verifyToken } from "../src/identity/token.js"; +import { nameTaken, findByToken } from "../src/identity/roster.js"; +import type { PlayerRecord } from "../src/store/tableStore.js"; + +describe("token", () => { + it("mints unique playerIds", () => { + expect(newPlayerId()).not.toBe(newPlayerId()); + }); + it("mints high-entropy tokens", () => { + const t = mintToken(); + expect(t.length).toBeGreaterThanOrEqual(32); + expect(mintToken()).not.toBe(t); + }); + it("hash is stable and verifies", () => { + const t = mintToken(); + const h = hashToken(t); + expect(hashToken(t)).toBe(h); + expect(verifyToken(t, h)).toBe(true); + expect(verifyToken(mintToken(), h)).toBe(false); + }); +}); + +describe("roster", () => { + const players: PlayerRecord[] = [ + { playerId: "p1", name: "Kira", role: "host", tokenHash: hashToken("tok-1"), createdAt: 1, lastSeenAt: 1 }, + ]; + it("nameTaken is case-insensitive", () => { + expect(nameTaken(players, "kira")).toBe(true); + expect(nameTaken(players, "KIRA")).toBe(true); + expect(nameTaken(players, "Bob")).toBe(false); + }); + it("findByToken matches the hashed seat", () => { + expect(findByToken(players, "tok-1")?.playerId).toBe("p1"); + expect(findByToken(players, "wrong")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `corepack pnpm --filter @gm/server test identity` +Expected: FAIL — cannot resolve `../src/identity/token.js`. + +- [ ] **Step 3: Implement token** + +Create `packages/server/src/identity/token.ts`: + +```ts +import { randomUUID, randomBytes, createHash, timingSafeEqual } from "node:crypto"; + +export function newPlayerId(): string { + return randomUUID(); +} + +export function mintToken(): string { + return randomBytes(24).toString("base64url"); // 192-bit +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function verifyToken(token: string, hash: string): boolean { + const a = Buffer.from(hashToken(token), "hex"); + const b = Buffer.from(hash, "hex"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} +``` + +- [ ] **Step 4: Implement roster** + +Create `packages/server/src/identity/roster.ts`: + +```ts +import type { PlayerRecord } from "../store/tableStore.js"; +import { verifyToken } from "./token.js"; + +export function nameTaken(players: PlayerRecord[], name: string): boolean { + const lower = name.toLowerCase(); + return players.some((p) => p.name.toLowerCase() === lower); +} + +export function findByToken(players: PlayerRecord[], token: string): PlayerRecord | null { + return players.find((p) => verifyToken(token, p.tokenHash)) ?? null; +} +``` + +- [ ] **Step 5: Run the test, verify it passes** + +Run: `corepack pnpm --filter @gm/server test identity` +Expected: PASS (5 tests). + +- [ ] **Step 6: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): account-less identity (token mint/hash/verify + roster helpers)" +``` + +--- + +## Task 7: `@gm/server` — room code generator + Room actor + +**Files:** +- Create: `packages/server/src/rooms/code.ts`, `packages/server/src/rooms/room.ts` +- Test: `packages/server/test/code.test.ts`, `packages/server/test/room.test.ts` + +**Interfaces:** +- Consumes: `@gm/protocol` (`makeEnvelope`, `ChatMessage`, `PlayerPublic`, `RoomSnapshot`), `RoomRecord`/`PlayerRecord` (Task 5), `node:crypto`. +- Produces: + - `code.ts`: `ROOM_ALPHABET` (string), `generateCode(rng?: () => number): string` (6 chars; `rng` defaults to a crypto-backed `[0,1)` source, injectable for tests). + - `room.ts`: `interface ClientSocket { send(data: string): void }` and `class Room`: + - constructor `(record: RoomRecord, players: PlayerRecord[], recentChat: ChatMessage[], deps: { config: Config; dataDir: string })`. + - `get code(): string`, `get record(): RoomRecord`, `get players(): PlayerRecord[]`, `get socketCount(): number`, `get lastActiveAt(): number`. + - `addPlayer(p: PlayerRecord): void`, `findPlayer(playerId): PlayerRecord | undefined`. + - `subscribe(playerId: string, socket: ClientSocket): void`, `unsubscribe(playerId: string, socket: ClientSocket): void`. + - `publicPlayers(): PlayerPublic[]` (`online` = has ≥1 socket). + - `sendTo(socket: ClientSocket, type: string, payload: unknown): void` (no seq). + - `broadcast(type: string, payload: unknown): void` (assigns seq, sends to all sockets). + - `buildSnapshot(): RoomSnapshot`. + - `appendChat(playerId, channel, text, clientId): Promise` — pushes to ring (cap 100), broadcasts `chat.message`, persists transcript + room record (seq, lastActiveAt). **Returns the built message.** + - `flush(): Promise` — persists the room record. + - `persistPlayers(): Promise` — persists the roster (`players.json`) through the room's **own** `dataDir`. This is the single source of truth for the table's path: the WS dispatcher (Task 10) must persist new seats via this method, never by naming a data dir itself. + +- [ ] **Step 1: Write the failing code-generator test** + +Create `packages/server/test/code.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { ROOM_ALPHABET, generateCode } from "../src/rooms/code.js"; + +describe("generateCode", () => { + it("is 6 chars from the Crockford-minus-ambiguous alphabet", () => { + for (let i = 0; i < 200; i++) { + const code = generateCode(); + expect(code).toMatch(/^[0-9A-Z]{6}$/); + for (const ch of code) expect(ROOM_ALPHABET).toContain(ch); + } + }); + it("excludes ambiguous characters 0 O 1 I L U", () => { + for (const ch of "01OILU") expect(ROOM_ALPHABET).not.toContain(ch); + }); + it("is deterministic under an injected rng", () => { + const rng = () => 0; // always first letter + expect(generateCode(rng)).toBe(ROOM_ALPHABET[0]!.repeat(6)); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test code` +Expected: FAIL — cannot resolve `../src/rooms/code.js`. + +- [ ] **Step 3: Implement the code generator** + +Create `packages/server/src/rooms/code.ts`: + +```ts +import { randomInt } from "node:crypto"; + +// Crockford base32 minus visually ambiguous 0 O 1 I L U. +export const ROOM_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; + +function cryptoRng(): number { + return randomInt(0, 1_000_000) / 1_000_000; +} + +export function generateCode(rng: () => number = cryptoRng): string { + let out = ""; + for (let i = 0; i < 6; i++) { + const idx = Math.min(ROOM_ALPHABET.length - 1, Math.floor(rng() * ROOM_ALPHABET.length)); + out += ROOM_ALPHABET[idx]; + } + return out; +} +``` + +- [ ] **Step 4: Run it, verify it passes** + +Run: `corepack pnpm --filter @gm/server test code` +Expected: PASS (3 tests). + +- [ ] **Step 5: Write the failing Room test** + +Create `packages/server/test/room.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseEnvelope } from "@gm/protocol"; +import { Room, type ClientSocket } from "../src/rooms/room.js"; +import { loadRoomRecord, type RoomRecord, type PlayerRecord } from "../src/store/tableStore.js"; +import { loadConfig } from "../src/config.js"; + +class FakeSocket implements ClientSocket { + sent: string[] = []; + send(data: string) { + this.sent.push(data); + } + types() { + return this.sent.map((s) => { + const r = parseEnvelope(s); + return r.ok ? r.envelope.type : "BAD"; + }); + } +} + +let dir: string; +const config = loadConfig({}); +const baseRec: RoomRecord = { + code: "ABC234", + phase: "active", + hostPlayerId: "p1", + createdAt: 1, + lastActiveAt: 1, + seq: 0, + schemaVersion: 1, +}; +const host: PlayerRecord = { + playerId: "p1", + name: "Kira", + role: "host", + tokenHash: "h", + createdAt: 1, + lastSeenAt: 1, +}; + +function newRoom(): Room { + return new Room({ ...baseRec }, [{ ...host }], [], { config, dataDir: dir }); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-room-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("Room", () => { + it("subscribe/unsubscribe drives the socket refcount and online flag", () => { + const room = newRoom(); + const s = new FakeSocket(); + expect(room.publicPlayers()[0]!.online).toBe(false); + room.subscribe("p1", s); + expect(room.socketCount).toBe(1); + expect(room.publicPlayers()[0]!.online).toBe(true); + room.unsubscribe("p1", s); + expect(room.socketCount).toBe(0); + expect(room.publicPlayers()[0]!.online).toBe(false); + }); + + it("broadcast assigns a monotonic seq and reaches every socket", () => { + const room = newRoom(); + const a = new FakeSocket(); + const b = new FakeSocket(); + room.subscribe("p1", a); + room.subscribe("p1", b); + room.broadcast("presence.update", { players: room.publicPlayers() }); + const ea = parseEnvelope(a.sent[0]!); + const eb = parseEnvelope(b.sent[0]!); + expect(ea.ok && eb.ok).toBe(true); + if (ea.ok && eb.ok) { + expect(ea.envelope.seq).toBe(1); + expect(eb.envelope.seq).toBe(1); + } + }); + + it("buildSnapshot carries the current seq watermark and roster", () => { + const room = newRoom(); + room.broadcast("presence.update", { players: [] }); // seq -> 1 + const snap = room.buildSnapshot(); + expect(snap.seq).toBe(1); + expect(snap.gmStatus).toBe("idle"); + expect(snap.players[0]!.playerId).toBe("p1"); + }); + + it("appendChat broadcasts, persists the transcript, and bumps the persisted seq", async () => { + const room = newRoom(); + const s = new FakeSocket(); + room.subscribe("p1", s); + const msg = await room.appendChat("p1", "ic", "hello table", "client-1"); + expect(msg.text).toBe("hello table"); + expect(s.types()).toContain("chat.message"); + const rec = await loadRoomRecord(dir, "ABC234"); + expect(rec!.seq).toBe(room.record.seq); + expect(rec!.seq).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 6: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test room` +Expected: FAIL — cannot resolve `../src/rooms/room.js`. + +- [ ] **Step 7: Implement the Room actor** + +Create `packages/server/src/rooms/room.ts`: + +```ts +import { + makeEnvelope, + type ChatMessage, + type PlayerPublic, + type RoomSnapshot, + type Channel, +} from "@gm/protocol"; +import { newPlayerId } from "../identity/token.js"; +import { appendTranscript, saveRoomRecord, savePlayers, type PlayerRecord, type RoomRecord } from "../store/tableStore.js"; +import type { Config } from "../config.js"; + +export interface ClientSocket { + send(data: string): void; +} + +const CHAT_RING = 100; + +export class Room { + private rec: RoomRecord; + private roster: PlayerRecord[]; + private recentChat: ChatMessage[]; + private sockets = new Map>(); + private readonly deps: { config: Config; dataDir: string }; + + constructor( + record: RoomRecord, + players: PlayerRecord[], + recentChat: ChatMessage[], + deps: { config: Config; dataDir: string }, + ) { + this.rec = record; + this.roster = players; + this.recentChat = recentChat.slice(-CHAT_RING); + this.deps = deps; + } + + get code(): string { + return this.rec.code; + } + get record(): RoomRecord { + return this.rec; + } + get players(): PlayerRecord[] { + return this.roster; + } + get lastActiveAt(): number { + return this.rec.lastActiveAt; + } + get socketCount(): number { + let n = 0; + for (const set of this.sockets.values()) n += set.size; + return n; + } + + addPlayer(p: PlayerRecord): void { + this.roster.push(p); + } + findPlayer(playerId: string): PlayerRecord | undefined { + return this.roster.find((p) => p.playerId === playerId); + } + + subscribe(playerId: string, socket: ClientSocket): void { + let set = this.sockets.get(playerId); + if (!set) { + set = new Set(); + this.sockets.set(playerId, set); + } + set.add(socket); + } + + unsubscribe(playerId: string, socket: ClientSocket): void { + const set = this.sockets.get(playerId); + if (!set) return; + set.delete(socket); + if (set.size === 0) this.sockets.delete(playerId); + } + + private isOnline(playerId: string): boolean { + const set = this.sockets.get(playerId); + return !!set && set.size > 0; + } + + publicPlayers(): PlayerPublic[] { + return this.roster.map((p) => ({ + playerId: p.playerId, + displayName: p.name, + role: p.role, + online: this.isOnline(p.playerId), + })); + } + + private nextSeq(): number { + this.rec.seq += 1; + return this.rec.seq; + } + + sendTo(socket: ClientSocket, type: string, payload: unknown): void { + socket.send(JSON.stringify(makeEnvelope({ type, room: this.rec.code, payload }))); + } + + broadcast(type: string, payload: unknown): void { + const seq = this.nextSeq(); + const wire = JSON.stringify(makeEnvelope({ type, room: this.rec.code, payload, seq })); + for (const set of this.sockets.values()) { + for (const socket of set) socket.send(wire); + } + } + + buildSnapshot(): RoomSnapshot { + return { + seq: this.rec.seq, + roomName: this.rec.code, + players: this.publicPlayers(), + scene: null, + characters: [], + pendingRollRequests: [], + transcript: this.recentChat.slice(-CHAT_RING), + activeStream: null, + gmStatus: "idle", + }; + } + + async appendChat( + playerId: string, + channel: Channel, + text: string, + clientId: string, + ): Promise { + const player = this.findPlayer(playerId); + const msg: ChatMessage = { + messageId: newPlayerId(), + clientId, + playerId, + displayName: player?.name ?? "unknown", + channel, + characterName: null, + text, + }; + this.recentChat.push(msg); + if (this.recentChat.length > CHAT_RING) this.recentChat.shift(); + this.broadcast("chat.message", msg); + this.rec.lastActiveAt = Date.now(); + await appendTranscript(this.deps.dataDir, this.rec.code, msg); + await saveRoomRecord(this.deps.dataDir, this.rec); + return msg; + } + + async flush(): Promise { + await saveRoomRecord(this.deps.dataDir, this.rec); + } + + async persistPlayers(): Promise { + await savePlayers(this.deps.dataDir, this.rec.code, this.roster); + } +} +``` + +- [ ] **Step 8: Run both Room/code tests, verify they pass** + +Run: `corepack pnpm --filter @gm/server test` +Expected: PASS (code 3 + room 4 + earlier suites). + +- [ ] **Step 9: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): room code generator + in-memory Room actor (seq/broadcast/snapshot/chat)" +``` + +--- + +## Task 8: `@gm/server` — RoomRegistry (create / rehydrate / evict) + +**Files:** +- Create: `packages/server/src/rooms/registry.ts` +- Test: `packages/server/test/registry.test.ts` + +**Interfaces:** +- Consumes: `Room` (Task 7), `generateCode` (Task 7), token + store modules. +- Produces `class RoomRegistry`: + - constructor `(deps: { config: Config; dataDir: string })`. + - `get size(): number` (live in-memory rooms). + - `createRoom(hostName: string): Promise<{ roomCode: string; playerId: string; token: string }>` — allocate a code (mkdir non-recursive collision arbiter, ≤5 tries), mint host seat, persist `room.json` + `players.json`, cache the Room. (A host-supplied table name is an M1+ addition per §3.3; M0's snapshot `roomName` is just the code.) + - `getOrRehydrate(code: string): Promise` — in-memory hit, else load from disk (null when absent), else cache. + - `list(): { code: string; phase: string; lastActiveAt: number; socketCount: number }[]`. + - `evict(code: string): Promise` — flush + drop. + - `sweepIdle(now: number): Promise` — evict rooms with `socketCount === 0 && now - lastActiveAt > IDLE_EVICT_MS`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/server/test/registry.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RoomRegistry } from "../src/rooms/registry.js"; +import { loadConfig } from "../src/config.js"; + +let dir: string; +function newRegistry(overrides: Record = {}) { + return new RoomRegistry({ config: loadConfig(overrides), dataDir: dir }); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-reg-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("RoomRegistry", () => { + it("creates a room with a host seat and a token", async () => { + const reg = newRegistry(); + const { roomCode, playerId, token } = await reg.createRoom("Kira"); + expect(roomCode).toMatch(/^[0-9A-Z]{6}$/); + expect(playerId.length).toBeGreaterThan(0); + expect(token.length).toBeGreaterThan(0); + expect(reg.size).toBe(1); + }); + + it("rehydrates a room from disk in a fresh registry (restart simulation)", async () => { + const reg1 = newRegistry(); + const { roomCode, playerId } = await reg1.createRoom("Kira"); + const room1 = await reg1.getOrRehydrate(roomCode); + await room1!.appendChat(playerId, "ic", "persisted line", "c1"); + const seqAfterChat = room1!.record.seq; + + const reg2 = newRegistry(); // same dir, no in-memory state + const room2 = await reg2.getOrRehydrate(roomCode); + expect(room2).not.toBeNull(); + expect(room2!.players[0]!.name).toBe("Kira"); + expect(room2!.record.seq).toBe(seqAfterChat); + expect(room2!.buildSnapshot().transcript.at(-1)!.text).toBe("persisted line"); + }); + + it("returns null for an unknown room", async () => { + expect(await newRegistry().getOrRehydrate("ZZZZZZ")).toBeNull(); + }); + + it("sweepIdle evicts only zero-socket, idle rooms", async () => { + const reg = newRegistry({ IDLE_EVICT_MS: "1000" }); + const { roomCode } = await reg.createRoom("Kira"); + await reg.getOrRehydrate(roomCode); + expect(reg.size).toBe(1); + await reg.sweepIdle(Date.now() + 5000); // far future, no sockets + expect(reg.size).toBe(0); + // still rehydratable from disk afterward + expect(await reg.getOrRehydrate(roomCode)).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test registry` +Expected: FAIL — cannot resolve `../src/rooms/registry.js`. + +- [ ] **Step 3: Implement the registry** + +Create `packages/server/src/rooms/registry.ts`: + +```ts +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { Room } from "./room.js"; +import { generateCode } from "./code.js"; +import { newPlayerId, mintToken, hashToken } from "../identity/token.js"; +import { + saveRoomRecord, + savePlayers, + loadRoomRecord, + loadPlayers, + tailTranscript, + type RoomRecord, + type PlayerRecord, +} from "../store/tableStore.js"; +import type { Config } from "../config.js"; + +export class RoomRegistry { + private rooms = new Map(); + private readonly config: Config; + private readonly dataDir: string; + + constructor(deps: { config: Config; dataDir: string }) { + this.config = deps.config; + this.dataDir = deps.dataDir; + } + + get size(): number { + return this.rooms.size; + } + + private async reserveCode(): Promise { + await mkdir(join(this.dataDir, "tables"), { recursive: true }); + for (let i = 0; i < 5; i++) { + const code = generateCode(); + try { + await mkdir(join(this.dataDir, "tables", code), { recursive: false }); + return code; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") continue; + throw err; + } + } + throw new Error("could not allocate a unique room code"); + } + + async createRoom( + hostName: string, + ): Promise<{ roomCode: string; playerId: string; token: string }> { + const code = await this.reserveCode(); + const now = Date.now(); + const playerId = newPlayerId(); + const token = mintToken(); + + const record: RoomRecord = { + code, + phase: "active", + hostPlayerId: playerId, + createdAt: now, + lastActiveAt: now, + seq: 0, + schemaVersion: 1, + }; + const host: PlayerRecord = { + playerId, + name: hostName, + role: "host", + tokenHash: hashToken(token), + createdAt: now, + lastSeenAt: now, + }; + await saveRoomRecord(this.dataDir, record); + await savePlayers(this.dataDir, code, [host]); + + this.rooms.set(code, new Room(record, [host], [], { config: this.config, dataDir: this.dataDir })); + return { roomCode: code, playerId, token }; + } + + async getOrRehydrate(code: string): Promise { + const existing = this.rooms.get(code); + if (existing) return existing; + const record = await loadRoomRecord(this.dataDir, code); + if (!record) return null; + const players = await loadPlayers(this.dataDir, code); + const recent = await tailTranscript(this.dataDir, code, 100); + const room = new Room(record, players, recent, { config: this.config, dataDir: this.dataDir }); + this.rooms.set(code, room); + return room; + } + + list(): { code: string; phase: string; lastActiveAt: number; socketCount: number }[] { + return [...this.rooms.values()].map((r) => ({ + code: r.code, + phase: r.record.phase, + lastActiveAt: r.lastActiveAt, + socketCount: r.socketCount, + })); + } + + async evict(code: string): Promise { + const room = this.rooms.get(code); + if (!room) return; + await room.flush(); + this.rooms.delete(code); + } + + async sweepIdle(now: number): Promise { + for (const room of [...this.rooms.values()]) { + if (room.socketCount === 0 && now - room.lastActiveAt > this.config.IDLE_EVICT_MS) { + await this.evict(room.code); + } + } + } +} +``` + +- [ ] **Step 4: Run it, verify it passes** + +Run: `corepack pnpm --filter @gm/server test registry` +Expected: PASS (4 tests). + +- [ ] **Step 5: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): RoomRegistry create/rehydrate/evict + idle sweep" +``` + +--- + +## Task 9: `@gm/server` — HTTP POST /api/rooms + real /healthz count + +**Files:** +- Modify: `packages/server/src/app.ts`, `packages/server/src/index.ts` +- Create: `packages/server/src/http/rooms.ts` +- Test: `packages/server/test/rooms-route.test.ts` + +**Interfaces:** +- Consumes: `buildApp`/`AppDeps` (Task 4), `RoomRegistry` (Task 8). +- Produces: + - Widened `interface AppDeps { config: Config; registry: RoomRegistry }`. + - `registerRoomRoutes(app, deps)` registering `POST /api/rooms` → `201 { roomCode, playerId, token }` (body `{ hostName: string }`, validated by zod; bad body → `400 { error }`). + - `/healthz.liveRooms` now reflects `registry.size`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/server/test/rooms-route.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; + +let dir: string; +async function appWithRegistry() { + const config = loadConfig({ DATA_DIR: dir }); + const registry = new RoomRegistry({ config, dataDir: dir }); + return { app: buildApp({ config, registry }), registry }; +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-route-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("POST /api/rooms", () => { + it("creates a room and returns code+token", async () => { + const { app } = await appWithRegistry(); + const res = await app.inject({ + method: "POST", + url: "/api/rooms", + payload: { hostName: "Kira" }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.roomCode).toMatch(/^[0-9A-Z]{6}$/); + expect(typeof body.token).toBe("string"); + await app.close(); + }); + + it("rejects a missing hostName with 400", async () => { + const { app } = await appWithRegistry(); + const res = await app.inject({ method: "POST", url: "/api/rooms", payload: {} }); + expect(res.statusCode).toBe(400); + await app.close(); + }); + + it("/healthz reports the live room count", async () => { + const { app } = await appWithRegistry(); + await app.inject({ method: "POST", url: "/api/rooms", payload: { hostName: "Kira" } }); + const res = await app.inject({ method: "GET", url: "/healthz" }); + expect(res.json().liveRooms).toBe(1); + await app.close(); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test rooms-route` +Expected: FAIL — `buildApp` does not accept `registry`; route missing. + +- [ ] **Step 3: Implement the route module** + +Create `packages/server/src/http/rooms.ts`: + +```ts +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import type { RoomRegistry } from "../rooms/registry.js"; + +const CreateRoomBody = z.object({ + hostName: z.string().min(1).max(40), +}); + +export function registerRoomRoutes(app: FastifyInstance, registry: RoomRegistry): void { + app.post("/api/rooms", async (req, reply) => { + const parsed = CreateRoomBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ error: "invalid body", detail: parsed.error.message }); + } + const result = await registry.createRoom(parsed.data.hostName); + return reply.code(201).send(result); + }); +} +``` + +- [ ] **Step 4: Widen AppDeps and wire the route + real healthz count** + +Replace `packages/server/src/app.ts` with: + +```ts +import Fastify, { type FastifyInstance } from "fastify"; +import type { Config } from "./config.js"; +import type { RoomRegistry } from "./rooms/registry.js"; +import { registerRoomRoutes } from "./http/rooms.js"; + +export interface AppDeps { + config: Config; + registry: RoomRegistry; +} + +export function buildApp(deps: AppDeps): FastifyInstance { + const app = Fastify({ logger: false }); + + app.get("/healthz", async () => ({ + status: "ok", + uptime: process.uptime(), + liveRooms: deps.registry.size, + })); + + registerRoomRoutes(app, deps.registry); + return app; +} +``` + +- [ ] **Step 5: Update the entry to construct a registry** + +Replace `packages/server/src/index.ts` with: + +```ts +import { buildApp } from "./app.js"; +import { loadConfig } from "./config.js"; +import { RoomRegistry } from "./rooms/registry.js"; + +const config = loadConfig(process.env); +const registry = new RoomRegistry({ config, dataDir: config.DATA_DIR }); +const app = buildApp({ config, registry }); + +const idleTimer = setInterval(() => { + void registry.sweepIdle(Date.now()); +}, 60_000); +idleTimer.unref(); + +async function main() { + await app.listen({ port: config.PORT, host: "0.0.0.0" }); + console.log(`[gm] listening on :${config.PORT}`); +} + +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[gm] ${signal} received, draining`); + clearInterval(idleTimer); + await app.close(); + process.exit(0); +} +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 6: Fix the now-broken Task 4 healthz test** + +The Task 4 `healthz.test.ts` calls `buildApp({ config })` without a registry. Update it to pass a registry: + +Replace the body of `packages/server/test/healthz.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-health-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("GET /healthz", () => { + it("returns 200 with status ok and a numeric uptime", async () => { + const config = loadConfig({ DATA_DIR: dir }); + const app = buildApp({ config, registry: new RoomRegistry({ config, dataDir: dir }) }); + const res = await app.inject({ method: "GET", url: "/healthz" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.status).toBe("ok"); + expect(typeof body.uptime).toBe("number"); + expect(typeof body.liveRooms).toBe("number"); + await app.close(); + }); +}); +``` + +- [ ] **Step 7: Run the server suite, verify green** + +Run: `corepack pnpm --filter @gm/server test` +Expected: PASS (all suites including the updated healthz + new rooms-route). + +- [ ] **Step 8: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): POST /api/rooms + healthz live-room count" +``` + +--- + +## Task 10: `@gm/server` — WS envelope dispatch (join/leave/chat/close/ping) + +**Files:** +- Create: `packages/server/src/ws/dispatch.ts` +- Test: `packages/server/test/dispatch.test.ts` + +**Interfaces:** +- Consumes: `@gm/protocol` (`parseEnvelope`, `parsePayload`, `C2S_SCHEMAS`, payload types, error codes), `RoomRegistry`, `Room`/`ClientSocket`, identity roster. +- Produces: + - `interface Connection { socket: ClientSocket; playerId: string | null; roomCode: string | null }`. + - `handleEnvelope(deps: { registry: RoomRegistry; config: Config }, conn: Connection, raw: string): Promise` — the full c2s dispatch. Sends errors via `error` envelopes; never throws on a bad/unknown message. + - `handleClose(deps, conn): void` — on socket close, unsubscribe + broadcast presence (does **not** mutate the roster — token↔seat binding persists). + - Internal helper `sendError(socket, code, message, refId)`. + +**Behavior contract (must match spec §3.3 / §5.2):** +- `room.join`: rehydrate room (null **or** `phase:"ended"` → `ROOM_NOT_FOUND`; a host-closed table is terminal). If `playerToken` matches a seat → resume (`resumed: true`, displayName ignored). Else new seat: reject `NAME_TAKEN` (case-insensitive) or `ROOM_FULL` (roster ≥ `ROOM_MAX_PLAYERS`); mint seat, persist the roster via `room.persistPlayers()`. Then **atomically** `buildSnapshot()` + `subscribe()` (no await between), send `room.joined` then `room.snapshot` to the joiner, broadcast `presence.update`. +- `chat.send`: requires a joined connection (`FORBIDDEN` otherwise); `room.appendChat`. The `clientId` echoed is the inbound envelope `id`. +- `room.leave`: unsubscribe this socket; broadcast presence. Roster untouched. +- `room.close`: host-only (`FORBIDDEN` otherwise); broadcast `room.closed`; evict. +- `system.ping`: reply `system.pong` (sender-only). +- unknown type / payload-validation failure: `system.ping`-style ignore for unknown types; `BAD_REQUEST` for a known type with an invalid payload. + +- [ ] **Step 1: Write the failing test** + +Create `packages/server/test/dispatch.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeEnvelope, parseEnvelope, type Envelope } from "@gm/protocol"; +import { handleEnvelope, handleClose, type Connection } from "../src/ws/dispatch.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; +import { loadConfig } from "../src/config.js"; +import type { ClientSocket } from "../src/rooms/room.js"; + +class FakeSocket implements ClientSocket { + sent: Envelope[] = []; + send(data: string) { + const r = parseEnvelope(data); + if (r.ok) this.sent.push(r.envelope); + } + ofType(type: string) { + return this.sent.filter((e) => e.type === type); + } + last(type: string) { + return this.ofType(type).at(-1); + } +} + +let dir: string; +let reg: RoomRegistry; +let deps: { registry: RoomRegistry; config: ReturnType }; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-disp-")); + const config = loadConfig({ DATA_DIR: dir, ROOM_MAX_PLAYERS: "2" }); + reg = new RoomRegistry({ config, dataDir: dir }); + deps = { registry: reg, config }; +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function conn(socket: ClientSocket): Connection { + return { socket, playerId: null, roomCode: null }; +} +function c2s(type: string, room: string | null, payload: unknown): string { + return JSON.stringify(makeEnvelope({ type, room, payload })); +} + +describe("handleEnvelope", () => { + it("host joins with token and gets joined+snapshot", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const s = new FakeSocket(); + const c = conn(s); + await handleEnvelope(deps, c, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + expect(s.last("room.joined")).toBeTruthy(); + expect((s.last("room.joined")!.payload as { resumed: boolean }).resumed).toBe(true); + expect(s.last("room.snapshot")).toBeTruthy(); + expect(c.playerId).not.toBeNull(); + }); + + it("unknown room → ROOM_NOT_FOUND", async () => { + const s = new FakeSocket(); + await handleEnvelope(deps, conn(s), c2s("room.join", "ZZZZZZ", { roomCode: "ZZZZZZ", displayName: "Bob" })); + expect((s.last("error")!.payload as { code: string }).code).toBe("ROOM_NOT_FOUND"); + }); + + it("a second new player joins, then a duplicate name is rejected", async () => { + const { roomCode } = await reg.createRoom("Kira"); + const s2 = new FakeSocket(); + await handleEnvelope(deps, conn(s2), c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + expect(s2.last("room.joined")).toBeTruthy(); + + const s3 = new FakeSocket(); + await handleEnvelope(deps, conn(s3), c2s("room.join", roomCode, { roomCode, displayName: "bob" })); + expect((s3.last("error")!.payload as { code: string }).code).toBe("NAME_TAKEN"); + }); + + it("ROOM_FULL once capacity is reached", async () => { + const { roomCode } = await reg.createRoom("Kira"); // 1/2 + const s2 = new FakeSocket(); + await handleEnvelope(deps, conn(s2), c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); // 2/2 + const s3 = new FakeSocket(); + await handleEnvelope(deps, conn(s3), c2s("room.join", roomCode, { roomCode, displayName: "Cy" })); + expect((s3.last("error")!.payload as { code: string }).code).toBe("ROOM_FULL"); + }); + + it("chat.send before join → FORBIDDEN; after join → broadcast to both", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const host = new FakeSocket(); + const hc = conn(host); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + + const guest = new FakeSocket(); + const gc = conn(guest); + await handleEnvelope(deps, gc, c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + + const bad = new FakeSocket(); + await handleEnvelope(deps, conn(bad), c2s("chat.send", roomCode, { text: "hi", channel: "ic" })); + expect((bad.last("error")!.payload as { code: string }).code).toBe("FORBIDDEN"); + + await handleEnvelope(deps, hc, c2s("chat.send", roomCode, { text: "hello table", channel: "ic" })); + expect(host.last("chat.message")).toBeTruthy(); + expect(guest.last("chat.message")).toBeTruthy(); + expect((guest.last("chat.message")!.payload as { text: string }).text).toBe("hello table"); + }); + + it("non-host close → FORBIDDEN; host close → room.closed + evicted", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const host = new FakeSocket(); + const hc = conn(host); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + const guest = new FakeSocket(); + const gc = conn(guest); + await handleEnvelope(deps, gc, c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + + await handleEnvelope(deps, gc, c2s("room.close", roomCode, {})); + expect((guest.last("error")!.payload as { code: string }).code).toBe("FORBIDDEN"); + + await handleEnvelope(deps, hc, c2s("room.close", roomCode, {})); + expect(host.last("room.closed")).toBeTruthy(); + expect(reg.size).toBe(0); + }); + + it("rejects joining a table the host has ended", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const hc = conn(new FakeSocket()); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + await handleEnvelope(deps, hc, c2s("room.close", roomCode, {})); // sets phase:ended, evicts (flush persists it) + + const late = new FakeSocket(); + // getOrRehydrate reloads the ended room from disk; the phase guard must reject the join. + await handleEnvelope(deps, conn(late), c2s("room.join", roomCode, { roomCode, displayName: "Late" })); + expect((late.last("error")!.payload as { code: string }).code).toBe("ROOM_NOT_FOUND"); + }); + + it("system.ping → system.pong", async () => { + const s = new FakeSocket(); + await handleEnvelope(deps, conn(s), c2s("system.ping", null, {})); + expect(s.last("system.pong")).toBeTruthy(); + }); + + it("unknown type is ignored (no error, no throw)", async () => { + const s = new FakeSocket(); + await handleEnvelope(deps, conn(s), c2s("map.move", null, { x: 1 })); + expect(s.sent).toHaveLength(0); + }); + + it("handleClose marks the player offline and broadcasts presence", async () => { + const { roomCode, token } = await reg.createRoom("Kira"); + const host = new FakeSocket(); + const hc = conn(host); + await handleEnvelope(deps, hc, c2s("room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token })); + const guest = new FakeSocket(); + const gc = conn(guest); + await handleEnvelope(deps, gc, c2s("room.join", roomCode, { roomCode, displayName: "Bob" })); + + handleClose(deps, gc); + const presence = host.last("presence.update")!.payload as { players: { displayName: string; online: boolean }[] }; + const bob = presence.players.find((p) => p.displayName === "Bob")!; + expect(bob.online).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test dispatch` +Expected: FAIL — cannot resolve `../src/ws/dispatch.js`. + +- [ ] **Step 3: Implement the dispatcher** + +Create `packages/server/src/ws/dispatch.ts`: + +```ts +import { + parseEnvelope, + parsePayload, + C2S_SCHEMAS, + PROTOCOL_VERSION, + makeEnvelope, + type RoomJoinReq, + type ChatSend, + type ErrorCode, +} from "@gm/protocol"; +import type { RoomRegistry } from "../rooms/registry.js"; +import type { Config } from "../config.js"; +import type { ClientSocket, Room } from "../rooms/room.js"; +import { nameTaken, findByToken } from "../identity/roster.js"; +import { newPlayerId, mintToken, hashToken } from "../identity/token.js"; +import { type PlayerRecord } from "../store/tableStore.js"; + +export interface Connection { + socket: ClientSocket; + playerId: string | null; + roomCode: string | null; +} + +interface Deps { + registry: RoomRegistry; + config: Config; +} + +function sendError(socket: ClientSocket, code: ErrorCode, message: string, refId: string | null): void { + socket.send(JSON.stringify(makeEnvelope({ type: "error", room: null, payload: { code, message, refId } }))); +} + +export async function handleEnvelope(deps: Deps, conn: Connection, raw: string): Promise { + const parsed = parseEnvelope(raw); + if (!parsed.ok) return sendError(conn.socket, "BAD_REQUEST", parsed.error, null); + const env = parsed.envelope; + + // Unknown types are silently ignored (additive-evolution rule). + if (!(env.type in C2S_SCHEMAS)) return; + + const payloadResult = parsePayload(env.type, env.payload, C2S_SCHEMAS); + if (!payloadResult.ok) return sendError(conn.socket, "BAD_REQUEST", payloadResult.error, env.id); + + switch (env.type) { + case "room.join": + return joinRoom(deps, conn, env.id, payloadResult.data as RoomJoinReq); + case "chat.send": + return chatSend(deps, conn, env.id, payloadResult.data as ChatSend); + case "room.leave": + return roomLeave(deps, conn); + case "room.close": + return roomClose(deps, conn, env.id); + case "system.ping": + conn.socket.send(JSON.stringify(makeEnvelope({ type: "system.pong", room: null, payload: {} }))); + return; + default: + return; + } +} + +async function joinRoom(deps: Deps, conn: Connection, refId: string, req: RoomJoinReq): Promise { + const room = await deps.registry.getOrRehydrate(req.roomCode); + if (!room) return sendError(conn.socket, "ROOM_NOT_FOUND", "no such room", refId); + if (room.record.phase === "ended") { + return sendError(conn.socket, "ROOM_NOT_FOUND", "this table has ended", refId); + } + + let seat: PlayerRecord | null = null; + let resumed = false; + let token = ""; + + if (req.playerToken) { + seat = findByToken(room.players, req.playerToken); + if (seat) { + resumed = true; + token = req.playerToken; + } + } + + if (!seat) { + if (nameTaken(room.players, req.displayName)) { + return sendError(conn.socket, "NAME_TAKEN", "that name is taken", refId); + } + if (room.players.length >= deps.config.ROOM_MAX_PLAYERS) { + return sendError(conn.socket, "ROOM_FULL", "this table is full", refId); + } + const now = Date.now(); + token = mintToken(); + seat = { + playerId: newPlayerId(), + name: req.displayName, + role: "player", + tokenHash: hashToken(token), + createdAt: now, + lastSeenAt: now, + }; + room.addPlayer(seat); + await room.persistPlayers(); + } + + // --- atomic block: no await between snapshot capture and subscribe --- + conn.playerId = seat.playerId; + conn.roomCode = room.code; + room.sendTo(conn.socket, "room.joined", { + roomCode: room.code, + playerId: seat.playerId, + playerToken: token, + resumed, + isHost: room.record.hostPlayerId === seat.playerId, + protocolVersion: PROTOCOL_VERSION, + }); + const snapshot = room.buildSnapshot(); + room.subscribe(seat.playerId, conn.socket); + room.sendTo(conn.socket, "room.snapshot", snapshot); + // --- end atomic block --- + + room.broadcast("presence.update", { players: room.publicPlayers() }); +} + +async function chatSend(deps: Deps, conn: Connection, refId: string, req: ChatSend): Promise { + const room = joinedRoom(deps, conn); + if (!room) return sendError(conn.socket, "FORBIDDEN", "join a room first", refId); + await room.appendChat(conn.playerId!, req.channel, req.text, refId); +} + +function roomLeave(deps: Deps, conn: Connection): void { + const room = joinedRoom(deps, conn); + if (!room) return; + room.unsubscribe(conn.playerId!, conn.socket); + room.broadcast("presence.update", { players: room.publicPlayers() }); +} + +async function roomClose(deps: Deps, conn: Connection, refId: string): Promise { + const room = joinedRoom(deps, conn); + if (!room) return sendError(conn.socket, "FORBIDDEN", "join a room first", refId); + if (room.record.hostPlayerId !== conn.playerId) { + return sendError(conn.socket, "FORBIDDEN", "host only", refId); + } + room.record.phase = "ended"; + room.broadcast("room.closed", { reason: "host ended the table" }); + await deps.registry.evict(room.code); +} + +export function handleClose(deps: Deps, conn: Connection): void { + const room = joinedRoom(deps, conn); + if (!room || !conn.playerId) return; + room.unsubscribe(conn.playerId, conn.socket); + room.broadcast("presence.update", { players: room.publicPlayers() }); +} + +function joinedRoom(deps: Deps, conn: Connection): Room | null { + if (!conn.roomCode || !conn.playerId) return null; + // getOrRehydrate is sync-fast on a live room; in M0 a joined conn's room is always in memory. + const room = (deps.registry as unknown as { rooms: Map }).rooms.get(conn.roomCode); + return room ?? null; +} +``` + +> **Note on `joinedRoom`:** reaching into `registry.rooms` is a deliberate shortcut for the synchronous post-join path. Task 8's `RoomRegistry` keeps `rooms` private; expose a `peek(code): Room | null` method on the registry instead of the cast. **Add this method in Task 8's `registry.ts`** (`peek(code) { return this.rooms.get(code) ?? null; }`) and replace the cast here with `deps.registry.peek(conn.roomCode)`. If you are executing strictly in order, add `peek` now via a one-line edit to `registry.ts` and a re-commit; the dispatcher then uses it cleanly. + +- [ ] **Step 4: Add `peek` to the registry (clean accessor)** + +Edit `packages/server/src/rooms/registry.ts` — add inside the class, after `getOrRehydrate`: + +```ts + peek(code: string): Room | null { + return this.rooms.get(code) ?? null; + } +``` + +Then in `dispatch.ts` replace the `joinedRoom` body with: + +```ts +function joinedRoom(deps: Deps, conn: Connection): Room | null { + if (!conn.roomCode || !conn.playerId) return null; + return deps.registry.peek(conn.roomCode); +} +``` + +- [ ] **Step 5: Run it, verify it passes** + +Run: `corepack pnpm --filter @gm/server test dispatch` +Expected: PASS (10 tests). + +- [ ] **Step 6: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): WS envelope dispatch (join/leave/chat/close/ping) + registry.peek" +``` + +--- + +## Task 11: `@gm/server` — WS heartbeat / dead-socket detection + +**Files:** +- Create: `packages/server/src/ws/heartbeat.ts` +- Test: `packages/server/test/heartbeat.test.ts` + +**Interfaces:** +- Consumes: nothing (pure, clock-injected via manual `tick()`). +- Produces `class Heartbeat`: + - constructor `(maxMissed: number)`. + - `register(socket: HeartbeatSocket, onDead: () => void): void`. + - `pong(socket: HeartbeatSocket): void` (resets the missed counter). + - `unregister(socket: HeartbeatSocket): void`. + - `tick(): void` — for each tracked socket: if `missed >= maxMissed` → `terminate()` + `onDead()` + drop; else `missed++` + `ping()`. + - `interface HeartbeatSocket { ping(): void; terminate(): void }`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/server/test/heartbeat.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { Heartbeat, type HeartbeatSocket } from "../src/ws/heartbeat.js"; + +class FakeWs implements HeartbeatSocket { + pings = 0; + terminated = false; + ping() { + this.pings++; + } + terminate() { + this.terminated = true; + } +} + +describe("Heartbeat", () => { + it("a socket that keeps ponging is never terminated", () => { + const hb = new Heartbeat(2); + const ws = new FakeWs(); + hb.register(ws, () => {}); + for (let i = 0; i < 10; i++) { + hb.tick(); + hb.pong(ws); + } + expect(ws.terminated).toBe(false); + }); + + it("a silent socket is terminated after maxMissed unanswered pings and fires onDead", () => { + const hb = new Heartbeat(2); + const ws = new FakeWs(); + let dead = false; + hb.register(ws, () => { + dead = true; + }); + hb.tick(); // missed 0->1, ping + hb.tick(); // missed 1->2, ping + expect(ws.terminated).toBe(false); + hb.tick(); // missed >= 2 -> terminate + expect(ws.terminated).toBe(true); + expect(dead).toBe(true); + }); + + it("unregister stops tracking", () => { + const hb = new Heartbeat(2); + const ws = new FakeWs(); + hb.register(ws, () => {}); + hb.unregister(ws); + hb.tick(); + expect(ws.pings).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test heartbeat` +Expected: FAIL — cannot resolve `../src/ws/heartbeat.js`. + +- [ ] **Step 3: Implement the heartbeat** + +Create `packages/server/src/ws/heartbeat.ts`: + +```ts +export interface HeartbeatSocket { + ping(): void; + terminate(): void; +} + +interface Entry { + missed: number; + onDead: () => void; +} + +export class Heartbeat { + private tracked = new Map(); + + constructor(private readonly maxMissed: number) {} + + register(socket: HeartbeatSocket, onDead: () => void): void { + this.tracked.set(socket, { missed: 0, onDead }); + } + + pong(socket: HeartbeatSocket): void { + const e = this.tracked.get(socket); + if (e) e.missed = 0; + } + + unregister(socket: HeartbeatSocket): void { + this.tracked.delete(socket); + } + + tick(): void { + for (const [socket, e] of [...this.tracked.entries()]) { + if (e.missed >= this.maxMissed) { + this.tracked.delete(socket); + socket.terminate(); + e.onDead(); + continue; + } + e.missed += 1; + socket.ping(); + } + } +} +``` + +- [ ] **Step 4: Run it, verify it passes** + +Run: `corepack pnpm --filter @gm/server test heartbeat` +Expected: PASS (3 tests). + +- [ ] **Step 5: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/server typecheck +git add packages/server +git commit -m "feat(server): WS heartbeat / dead-socket detection (clock-injected)" +``` + +--- + +## Task 12: `@gm/server` — Fastify WS wiring + e2e smoke (M0 acceptance) + +**Files:** +- Modify: `packages/server/src/app.ts` (register `@fastify/websocket`, wire the connection handler) +- Create: `packages/server/src/ws/gateway.ts` (per-process wiring: heartbeat interval + connection setup) +- Test: `packages/server/test/e2e.test.ts` + +**Interfaces:** +- Consumes: `buildApp` (Task 9), `handleEnvelope`/`handleClose` (Task 10), `Heartbeat` (Task 11). +- Produces: + - `registerGateway(app, deps): void` — registers `@fastify/websocket`, a `GET /ws` route; per connection: build a `Connection`, register with the heartbeat, wire `message`/`pong`/`close`; one `setInterval(WS_PING_MS)` drives `heartbeat.tick()`, cleared on app `onClose`. + - The e2e test is the **M0 acceptance gate**. + +**Real-socket adapter:** `@fastify/websocket` hands the route a raw `ws` `WebSocket`. It already has `.send`, `.ping`, `.terminate`, and a `'pong'` event — so it satisfies both `ClientSocket` and `HeartbeatSocket` directly. + +- [ ] **Step 1: Write the failing e2e test** + +Create `packages/server/test/e2e.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WebSocket } from "ws"; +import type { AddressInfo } from "node:net"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { RoomRegistry } from "../src/rooms/registry.js"; +import { makeEnvelope, parseEnvelope, type Envelope } from "@gm/protocol"; + +let dir: string; +let app: Awaited>; +let baseUrl: string; +let wsUrl: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "gm-e2e-")); + const config = loadConfig({ DATA_DIR: dir, PORT: "0", WS_PING_MS: "50", WS_MAX_MISSED_PONGS: "1" }); + const registry = new RoomRegistry({ config, dataDir: dir }); + app = buildApp({ config, registry }); + await app.listen({ port: 0, host: "127.0.0.1" }); + const addr = app.server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + wsUrl = `ws://127.0.0.1:${addr.port}/ws`; +}); +afterEach(async () => { + await app.close(); + await rm(dir, { recursive: true, force: true }); +}); + +function open(): Promise { + const ws = new WebSocket(wsUrl); + return new Promise((res, rej) => { + ws.on("open", () => res(ws)); + ws.on("error", rej); + }); +} +function send(ws: WebSocket, type: string, room: string | null, payload: unknown) { + ws.send(JSON.stringify(makeEnvelope({ type, room, payload }))); +} +function waitFor(ws: WebSocket, type: string, timeoutMs = 2000): Promise { + return new Promise((res, rej) => { + const onMessage = (data: Buffer) => { + const r = parseEnvelope(data.toString()); + if (r.ok && r.envelope.type === type) { + clearTimeout(timer); + ws.off("message", onMessage); // one-shot: don't leak listeners across the test + res(r.envelope); + } + }; + const timer = setTimeout(() => { + ws.off("message", onMessage); + rej(new Error(`timeout waiting for ${type}`)); + }, timeoutMs); + ws.on("message", onMessage); + }); +} +async function createRoom(hostName: string): Promise<{ roomCode: string; token: string }> { + const res = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName }), + }); + return res.json(); +} + +describe("M0 e2e acceptance", () => { + it("two clients in one room see each other's messages (both directions)", async () => { + const { roomCode, token } = await createRoom("Kira"); + + const host = await open(); + const hostJoined = waitFor(host, "room.snapshot"); + send(host, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token }); + await hostJoined; + + const guest = await open(); + const guestJoined = waitFor(guest, "room.snapshot"); + const hostSeesGuest = waitFor(host, "presence.update"); + send(guest, "room.join", roomCode, { roomCode, displayName: "Bob" }); + await guestJoined; + await hostSeesGuest; + + // host → guest + const guestGetsChat = waitFor(guest, "chat.message"); + send(host, "chat.send", roomCode, { text: "roll initiative", channel: "ic" }); + expect(((await guestGetsChat).payload as { text: string }).text).toBe("roll initiative"); + + // guest → host (the criterion is mutual: "see each other's messages") + const hostGetsChat = waitFor(host, "chat.message"); + send(guest, "chat.send", roomCode, { text: "i ready my bow", channel: "ic" }); + expect(((await hostGetsChat).payload as { text: string }).text).toBe("i ready my bow"); + + host.close(); + guest.close(); + }); + + it("reconnect with a stored token restores the same seat and chat history", async () => { + const { roomCode, token } = await createRoom("Kira"); + + const a = await open(); + const aJoined = waitFor(a, "room.joined"); + const aSnap = waitFor(a, "room.snapshot"); + send(a, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token }); + const firstJoin = (await aJoined).payload as { playerId: string }; + await aSnap; + + // Write state, confirm it landed, THEN hard-drop the socket. + const aOwnChat = waitFor(a, "chat.message"); + send(a, "chat.send", roomCode, { text: "we make camp", channel: "ic" }); + await aOwnChat; + a.terminate(); // hard drop, no clean close + + const b = await open(); + const bJoined = waitFor(b, "room.joined"); + const bSnap = waitFor(b, "room.snapshot"); + send(b, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token }); + const joined = (await bJoined).payload as { resumed: boolean; playerId: string }; + const snap = (await bSnap).payload as { + players: unknown[]; + transcript: { text: string }[]; + seq: number; + }; + + expect(joined.resumed).toBe(true); // resumed the seat, did not mint a new one + expect(joined.playerId).toBe(firstJoin.playerId); // same identity + expect(snap.players.length).toBe(1); // no duplicate "Kira" + expect(snap.seq).toBeGreaterThan(0); // seq watermark survived + expect(snap.transcript.at(-1)!.text).toBe("we make camp"); // history actually restored + b.close(); + }); + + it("an abruptly-killed socket leaves no ghost player", async () => { + // Proves the §8 OUTCOME (no ghost) end-to-end. A genuinely half-open socket + // (no FIN/RST — laptop lid) is hard to forge over loopback, so the heartbeat + // REAPING LOGIC for that case is unit-tested in Task 11 (heartbeat.test.ts: + // a silent socket terminates after maxMissed ticks). Here either path — the + // close event OR the heartbeat — must flip Bob offline. + const { roomCode, token } = await createRoom("Kira"); + const host = await open(); + const hSnap = waitFor(host, "room.snapshot"); + send(host, "room.join", roomCode, { roomCode, displayName: "Kira", playerToken: token }); + await hSnap; + + const guest = await open(); + const gSnap = waitFor(guest, "room.snapshot"); + const hSeesGuest = waitFor(host, "presence.update"); + send(guest, "room.join", roomCode, { roomCode, displayName: "Bob" }); + await gSnap; + await hSeesGuest; + + // Register the offline-watcher BEFORE the kill (no race), with its own cleanup. + const guestOffline = new Promise((resolve) => { + const onMessage = (data: Buffer) => { + const r = parseEnvelope(data.toString()); + if (r.ok && r.envelope.type === "presence.update") { + const players = (r.envelope.payload as { players: { displayName: string; online: boolean }[] }).players; + const bob = players.find((p) => p.displayName === "Bob"); + if (bob && bob.online === false) { + host.off("message", onMessage); + resolve(); + } + } + }; + host.on("message", onMessage); + }); + + guest.terminate(); // abrupt RST, no clean WS close handshake + + await guestOffline; // close-path or heartbeat (WS_PING_MS=50, maxMissed=1) flips Bob offline + host.close(); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `corepack pnpm --filter @gm/server test e2e` +Expected: FAIL — `/ws` route does not exist (connection refused / 404). + +- [ ] **Step 3: Implement the gateway wiring** + +Create `packages/server/src/ws/gateway.ts`: + +```ts +import type { FastifyInstance } from "fastify"; +import fastifyWebsocket from "@fastify/websocket"; +import type { WebSocket } from "ws"; +import { handleEnvelope, handleClose, type Connection } from "./dispatch.js"; +import { Heartbeat } from "./heartbeat.js"; +import type { RoomRegistry } from "../rooms/registry.js"; +import type { Config } from "../config.js"; + +export async function registerGateway( + app: FastifyInstance, + deps: { registry: RoomRegistry; config: Config }, +): Promise { + await app.register(fastifyWebsocket); + + const heartbeat = new Heartbeat(deps.config.WS_MAX_MISSED_PONGS); + const timer = setInterval(() => heartbeat.tick(), deps.config.WS_PING_MS); + timer.unref(); + app.addHook("onClose", async () => clearInterval(timer)); + + app.get("/ws", { websocket: true }, (socket: WebSocket) => { + const conn: Connection = { socket, playerId: null, roomCode: null }; + + heartbeat.register(socket, () => handleClose(deps, conn)); + socket.on("pong", () => heartbeat.pong(socket)); + + socket.on("message", (data: Buffer) => { + void handleEnvelope(deps, conn, data.toString()); + }); + socket.on("close", () => { + heartbeat.unregister(socket); + handleClose(deps, conn); + }); + }); +} +``` + +> **Adapter note:** the `ws` `WebSocket` `.send(data)` accepts a string, satisfying `ClientSocket`; `.ping()`/`.terminate()` satisfy `HeartbeatSocket`. No wrapper needed. The `socket` parameter type from `@fastify/websocket` v11 is the raw `ws` socket. + +- [ ] **Step 4: Register the gateway in buildApp** + +The connection handler must be registered. Because `@fastify/websocket` registration is async, expose an async wiring step. Edit `packages/server/src/app.ts` — change `buildApp` to register the gateway and return the app (Fastify allows registering async plugins without awaiting; they resolve during `listen()`/`ready()`): + +Replace `packages/server/src/app.ts` with: + +```ts +import Fastify, { type FastifyInstance } from "fastify"; +import type { Config } from "./config.js"; +import type { RoomRegistry } from "./rooms/registry.js"; +import { registerRoomRoutes } from "./http/rooms.js"; +import { registerGateway } from "./ws/gateway.js"; + +export interface AppDeps { + config: Config; + registry: RoomRegistry; +} + +export function buildApp(deps: AppDeps): FastifyInstance { + const app = Fastify({ logger: false }); + + app.get("/healthz", async () => ({ + status: "ok", + uptime: process.uptime(), + liveRooms: deps.registry.size, + })); + + registerRoomRoutes(app, deps.registry); + void registerGateway(app, deps); + return app; +} +``` + +- [ ] **Step 5: Run the e2e, verify it passes** + +Run: `corepack pnpm --filter @gm/server test e2e` +Expected: PASS (3 acceptance tests). If the ghost-player test flakes, confirm `WS_PING_MS=50`/`WS_MAX_MISSED_PONGS=1` in the test config (the heartbeat is the fallback reaper if the RST's close event is somehow missed). + +- [ ] **Step 6: Run the full server suite + typecheck** + +Run: +```bash +corepack pnpm --filter @gm/server test +corepack pnpm --filter @gm/server typecheck +``` +Expected: all suites PASS, typecheck clean. + +- [ ] **Step 7: Commit** + +```bash +git add packages/server pnpm-lock.yaml +git commit -m "feat(server): Fastify WS gateway wiring + M0 e2e acceptance (chat/reconnect/no-ghost)" +``` + +--- + +## Task 13: `@gm/web` — scaffold + store reducer + WS client + +**Files:** +- Create: `packages/web/package.json`, `packages/web/tsconfig.json`, `packages/web/vite.config.ts`, `packages/web/vitest.config.ts`, `packages/web/index.html`, `packages/web/tailwind.config.js`, `packages/web/postcss.config.js`, `packages/web/src/index.css`, `packages/web/src/store.ts`, `packages/web/src/wsClient.ts`, `packages/web/src/api.ts` +- Test: `packages/web/test/store.test.ts` + +**Interfaces:** +- Consumes: `@gm/protocol` (`Envelope`, payload types). +- Produces: + - `store.ts`: a zustand store with state `{ status, roomCode, self, players, messages, lastSeq, needsRecover, error }` and actions `applyEvent(env: Envelope): void`, `reset(): void`, `setStatus(s): void`. `applyEvent` implements seq-gating (`<=lastSeq` discard, `==lastSeq+1` apply, gap → `needsRecover=true`) for seq-bearing broadcasts; snapshot resets `lastSeq`. + - `wsClient.ts`: `connectRoom(opts): RoomConnection` with reconnect/backoff + `localStorage` token persistence (`gm:token:`); on open it sends `room.join`; on a recover gap it re-sends `room.join`. + - `api.ts`: `createRoom(hostName): Promise<{ roomCode, playerId, token }>`. + +- [ ] **Step 1: Create the package manifest** + +Create `packages/web/package.json`: + +```json +{ + "name": "@gm/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@gm/protocol": "workspace:*", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig, vite, vitest, tailwind, postcss, index.html, css** + +Create `packages/web/tsconfig.json`: + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "test"] +} +``` + +Create `packages/web/vite.config.ts`: + +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:8787", + "/ws": { target: "ws://localhost:8787", ws: true }, + }, + }, +}); +``` + +Create `packages/web/vitest.config.ts`: + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + server: { deps: { inline: [/@gm\/protocol/] } }, + }, +}); +``` + +Create `packages/web/tailwind.config.js`: + +```js +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { extend: {} }, + plugins: [], +}; +``` + +Create `packages/web/postcss.config.js`: + +```js +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; +``` + +Create `packages/web/index.html`: + +```html + + + + + + GM Table + + +
+ + + +``` + +Create `packages/web/src/index.css`: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +- [ ] **Step 3: Write the failing store test** + +Create `packages/web/test/store.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import { makeEnvelope } from "@gm/protocol"; +import { useStore } from "../src/store.js"; + +beforeEach(() => useStore.getState().reset()); + +function feed(type: string, payload: unknown, seq?: number) { + useStore.getState().applyEvent(makeEnvelope({ type, room: "ABC234", payload, seq })); +} + +describe("store.applyEvent", () => { + it("room.snapshot resets lastSeq and seeds players + transcript", () => { + feed("room.snapshot", { + seq: 5, + roomName: "ABC234", + players: [{ playerId: "p1", displayName: "Kira", role: "host", online: true }], + scene: null, + characters: [], + pendingRollRequests: [], + transcript: [ + { messageId: "m1", clientId: "c", playerId: "p1", displayName: "Kira", channel: "ic", characterName: null, text: "old line" }, + ], + activeStream: null, + gmStatus: "idle", + }); + const s = useStore.getState(); + expect(s.lastSeq).toBe(5); + expect(s.players).toHaveLength(1); + expect(s.messages).toHaveLength(1); + }); + + it("applies the next-in-order chat.message and advances lastSeq", () => { + feed("room.snapshot", baseSnapshot(5)); + feed("chat.message", chat("m2", "next"), 6); + const s = useStore.getState(); + expect(s.messages.at(-1)!.text).toBe("next"); + expect(s.lastSeq).toBe(6); + }); + + it("discards an already-seen seq", () => { + feed("room.snapshot", baseSnapshot(5)); + feed("chat.message", chat("dupe", "old"), 5); + expect(useStore.getState().messages.find((m) => m.text === "old")).toBeUndefined(); + }); + + it("flags needsRecover on a seq gap", () => { + feed("room.snapshot", baseSnapshot(5)); + feed("chat.message", chat("future", "skipped"), 8); + expect(useStore.getState().needsRecover).toBe(true); + }); + + it("presence.update replaces the roster", () => { + feed("room.snapshot", baseSnapshot(5)); + feed("presence.update", { players: [] }, 6); + expect(useStore.getState().players).toHaveLength(0); + }); +}); + +function chat(messageId: string, text: string) { + return { messageId, clientId: "c", playerId: "p1", displayName: "Kira", channel: "ic", characterName: null, text }; +} +function baseSnapshot(seq: number) { + return { + seq, + roomName: "ABC234", + players: [{ playerId: "p1", displayName: "Kira", role: "host", online: true }], + scene: null, + characters: [], + pendingRollRequests: [], + transcript: [], + activeStream: null, + gmStatus: "idle", + }; +} +``` + +- [ ] **Step 4: Run it, verify it fails** + +Run: `corepack pnpm install && corepack pnpm --filter @gm/web test` + +The `install` materializes the new web deps before the first run; without it a non-TTY subagent shell aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. + +Expected: FAIL — cannot resolve `../src/store.js`. + +- [ ] **Step 5: Implement the store** + +Create `packages/web/src/store.ts`: + +```ts +import { create } from "zustand"; +import type { Envelope, ChatMessage, PlayerPublic, RoomSnapshot, RoomJoined } from "@gm/protocol"; + +export type Status = "disconnected" | "connecting" | "connected"; + +interface StoreState { + status: Status; + roomCode: string | null; + self: { playerId: string; isHost: boolean } | null; + players: PlayerPublic[]; + messages: ChatMessage[]; + lastSeq: number; + needsRecover: boolean; + error: string | null; + setStatus(s: Status): void; + applyEvent(env: Envelope): void; + reset(): void; +} + +const SEQ_BEARING = new Set(["chat.message", "presence.update", "room.closed"]); + +export const useStore = create((set, get) => ({ + status: "disconnected", + roomCode: null, + self: null, + players: [], + messages: [], + lastSeq: 0, + needsRecover: false, + error: null, + + setStatus: (s) => set({ status: s }), + reset: () => + set({ + status: "disconnected", + roomCode: null, + self: null, + players: [], + messages: [], + lastSeq: 0, + needsRecover: false, + error: null, + }), + + applyEvent: (env) => { + // Snapshot is the recovery anchor — always apply, reset the watermark. + if (env.type === "room.snapshot") { + const snap = env.payload as RoomSnapshot; + set({ + roomCode: snap.roomName, + players: snap.players, + messages: snap.transcript, + lastSeq: snap.seq, + needsRecover: false, + status: "connected", + }); + return; + } + if (env.type === "room.joined") { + const j = env.payload as RoomJoined; + set({ self: { playerId: j.playerId, isHost: j.isHost }, roomCode: j.roomCode }); + return; + } + if (env.type === "error") { + set({ error: (env.payload as { message: string }).message }); + return; + } + if (env.type === "system.pong") return; + + // Seq-gated broadcasts. + if (SEQ_BEARING.has(env.type) && typeof env.seq === "number") { + const seq = env.seq; + const last = get().lastSeq; + if (seq <= last) return; // already seen + if (seq > last + 1) { + set({ needsRecover: true }); // gap → wsClient re-joins + return; + } + switch (env.type) { + case "chat.message": + set((st) => ({ messages: [...st.messages, env.payload as ChatMessage], lastSeq: seq })); + return; + case "presence.update": + set({ players: (env.payload as { players: PlayerPublic[] }).players, lastSeq: seq }); + return; + case "room.closed": + set({ status: "disconnected", lastSeq: seq, error: (env.payload as { reason: string }).reason }); + return; + } + } + }, +})); +``` + +- [ ] **Step 6: Run it, verify it passes** + +Run: `corepack pnpm --filter @gm/web test` +Expected: PASS (5 tests). + +- [ ] **Step 7: Implement the API client and WS client (no unit test — exercised manually in Task 14)** + +Create `packages/web/src/api.ts`: + +```ts +export async function createRoom( + hostName: string, +): Promise<{ roomCode: string; playerId: string; token: string }> { + const res = await fetch("/api/rooms", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostName }), + }); + if (!res.ok) throw new Error(`createRoom failed: ${res.status}`); + return res.json(); +} +``` + +Create `packages/web/src/wsClient.ts`: + +```ts +import { makeEnvelope, parseEnvelope } from "@gm/protocol"; +import { useStore } from "./store.js"; + +interface RoomCreds { + playerId: string; + token: string; +} + +function tokenKey(code: string) { + return `gm:token:${code}`; +} +export function loadCreds(code: string): RoomCreds | null { + const raw = localStorage.getItem(tokenKey(code)); + return raw ? (JSON.parse(raw) as RoomCreds) : null; +} +export function saveCreds(code: string, creds: RoomCreds): void { + localStorage.setItem(tokenKey(code), JSON.stringify(creds)); +} + +export interface RoomConnection { + send(type: string, payload: unknown): void; + close(): void; +} + +export function connectRoom(opts: { roomCode: string; displayName: string }): RoomConnection { + let ws: WebSocket | null = null; + let backoff = 500; + let closed = false; + + function join() { + const creds = loadCreds(opts.roomCode); + ws!.send( + JSON.stringify( + makeEnvelope({ + type: "room.join", + room: opts.roomCode, + payload: { + roomCode: opts.roomCode, + displayName: opts.displayName, + ...(creds ? { playerToken: creds.token } : {}), + }, + }), + ), + ); + } + + function open() { + useStore.getState().setStatus("connecting"); + const proto = location.protocol === "https:" ? "wss" : "ws"; + ws = new WebSocket(`${proto}://${location.host}/ws`); + + ws.onopen = () => { + backoff = 500; + join(); + }; + ws.onmessage = (ev) => { + const r = parseEnvelope(typeof ev.data === "string" ? ev.data : ""); + if (!r.ok) return; + if (r.envelope.type === "room.joined") { + const j = r.envelope.payload as { playerId: string; playerToken: string }; + saveCreds(opts.roomCode, { playerId: j.playerId, token: j.playerToken }); + } + useStore.getState().applyEvent(r.envelope); + if (useStore.getState().needsRecover) join(); // gap recovery = re-join + }; + ws.onclose = () => { + if (closed) return; + useStore.getState().setStatus("disconnected"); + backoff = Math.min(backoff * 2, 8000); + setTimeout(open, backoff); + }; + ws.onerror = () => ws?.close(); + } + + open(); + + return { + send(type, payload) { + ws?.send(JSON.stringify(makeEnvelope({ type, room: opts.roomCode, payload }))); + }, + close() { + closed = true; + ws?.close(); + }, + }; +} +``` + +- [ ] **Step 8: Typecheck and commit** + +```bash +corepack pnpm --filter @gm/web typecheck +git add packages/web pnpm-lock.yaml +git commit -m "feat(web): scaffold + zustand store (seq-gated applyEvent) + ws/api clients" +``` + +--- + +## Task 14: `@gm/web` — UI components + manual two-browser acceptance + +**Files:** +- Create: `packages/web/src/main.tsx`, `packages/web/src/App.tsx`, `packages/web/src/components/JoinForm.tsx`, `packages/web/src/components/RoomView.tsx`, `packages/web/src/components/PresenceList.tsx`, `packages/web/src/components/ChatLog.tsx`, `packages/web/src/components/ChatInput.tsx` + +**Interfaces:** +- Consumes: `useStore` (Task 13), `connectRoom`, `createRoom`. +- Produces: a working SPA. **No unit tests** (UI pixels; spec §7 "Not tested"). Deliverable verified by manual two-browser acceptance. + +- [ ] **Step 1: Implement the entry** + +Create `packages/web/src/main.tsx`: + +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); +``` + +- [ ] **Step 2: Implement the App shell (join vs room)** + +Create `packages/web/src/App.tsx`: + +```tsx +import { useState } from "react"; +import { JoinForm } from "./components/JoinForm.js"; +import { RoomView } from "./components/RoomView.js"; +import { connectRoom, type RoomConnection } from "./wsClient.js"; + +export function App() { + const [conn, setConn] = useState(null); + + if (!conn) { + return ( + setConn(connectRoom({ roomCode, displayName }))} + /> + ); + } + return ; +} +``` + +- [ ] **Step 3: Implement JoinForm (create or join)** + +Create `packages/web/src/components/JoinForm.tsx`: + +```tsx +import { useState } from "react"; +import { createRoom } from "../api.js"; + +export function JoinForm({ onJoin }: { onJoin: (roomCode: string, displayName: string) => void }) { + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + + async function handleCreate() { + if (!name) return; + setBusy(true); + try { + const { roomCode } = await createRoom(name); + onJoin(roomCode, name); + } finally { + setBusy(false); + } + } + + return ( +
+

GM Table

+ setName(e.target.value)} + /> + setCode(e.target.value.toUpperCase())} + /> +
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: Implement RoomView + PresenceList + ChatLog + ChatInput** + +Create `packages/web/src/components/PresenceList.tsx`: + +```tsx +import { useStore } from "../store.js"; + +export function PresenceList() { + const players = useStore((s) => s.players); + return ( +
    + {players.map((p) => ( +
  • + + {p.displayName} + {p.role === "host" && (host)} +
  • + ))} +
+ ); +} +``` + +Create `packages/web/src/components/ChatLog.tsx`: + +```tsx +import { useStore } from "../store.js"; + +export function ChatLog() { + const messages = useStore((s) => s.messages); + return ( +
+ {messages.map((m) => ( +
+ {m.displayName}: {m.text} + {m.channel === "ooc" && (ooc)} +
+ ))} +
+ ); +} +``` + +Create `packages/web/src/components/ChatInput.tsx`: + +```tsx +import { useState } from "react"; +import type { RoomConnection } from "../wsClient.js"; + +export function ChatInput({ conn }: { conn: RoomConnection }) { + const [text, setText] = useState(""); + const [channel, setChannel] = useState<"ic" | "ooc">("ic"); + + function submit() { + const trimmed = text.trim(); + if (!trimmed) return; + conn.send("chat.send", { text: trimmed, channel }); + setText(""); + } + + return ( +
+ + setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
+ ); +} +``` + +Create `packages/web/src/components/RoomView.tsx`: + +```tsx +import { useStore } from "../store.js"; +import type { RoomConnection } from "../wsClient.js"; +import { PresenceList } from "./PresenceList.js"; +import { ChatLog } from "./ChatLog.js"; +import { ChatInput } from "./ChatInput.js"; + +export function RoomView({ conn }: { conn: RoomConnection }) { + const roomCode = useStore((s) => s.roomCode); + const status = useStore((s) => s.status); + const error = useStore((s) => s.error); + + return ( +
+ +
+ {error &&
{error}
} + + +
+
+ ); +} +``` + +- [ ] **Step 5: Typecheck** + +Run: `corepack pnpm --filter @gm/web typecheck` +Expected: clean. + +- [ ] **Step 6: Manual two-browser acceptance (the M0 done-gate)** + +In two terminals (M0 needs no `.env` — `config.ts` defaults put the server on `:8787`, which the Vite dev proxy already targets): +```bash +# terminal 1 (or: corepack pnpm dev — runs both via concurrently) +corepack pnpm --filter @gm/server dev +# terminal 2 +corepack pnpm --filter @gm/web dev +``` +Then: +1. Open the Vite URL (e.g. `http://localhost:5173`) in **two** browser windows. +2. Window A: type a name, click **Create**. Note the room code shown in the sidebar. +3. Window B: type a different name, paste the code, click **Join**. +4. Confirm both sidebars list both players, each marked online (green). +5. Type in A → the message appears in **both** logs. Type in B → appears in both. +6. In B, hard-close the tab. Within ~1 minute, A's sidebar shows B as offline (gray) — **no ghost**. +7. Reopen B to the same Vite URL and Join with the same name → B resumes its seat (token from `localStorage`) and the chat history reappears from the snapshot. + +Record the result (pass/fail per step) in the commit message. + +- [ ] **Step 7: Commit** + +```bash +git add packages/web +git commit -m "feat(web): join/room UI (presence, chat log, chat input) + manual M0 acceptance" +``` + +- [ ] **Step 8: Final M0 gate — whole-workspace green** + +Run: +```bash +corepack pnpm -r typecheck +corepack pnpm -r test +``` +Expected: every package typechecks and all suites pass. This is the M0 completion gate. + +--- + +## Notes for the executor + +- **Run order matters for two cross-task edits:** Task 9 widens `AppDeps` and updates the Task 4 healthz test; Task 10 adds `registry.peek` (used by the dispatcher) and Task 12 replaces `buildApp` to register the gateway. If you execute out of order, those edits will look like conflicts — follow the task sequence. +- **`@gm/protocol` is imported as `.ts` source.** Both server and web vitest configs inline it (`server.deps.inline`). If a test fails to resolve `@gm/protocol`, that inline rule is missing. +- **Import specifiers use `.js`** even for `.ts` files (ESM + `moduleResolution: Bundler` convention used throughout). Keep it consistent. +- **Dev runs under `tsx` (Task 4 dev script), not bare `node`:** Node 22's native type-stripping does NOT remap `.js`→`.ts` specifiers, so `node src/index.ts` crashes on its first relative import (`ERR_MODULE_NOT_FOUND`); `tsx watch` resolves them. Prod uses `tsup`-built real `.js` (`start` runs `dist/index.js`). +- **Run `corepack pnpm install` after any task that adds a new `package.json`** (Tasks 2, 4, 13 fold this into their first test step). A non-TTY subagent shell aborts a stale-lockfile auto-install with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` otherwise. +- This plan delivers M0 only. M1 (engine turn loop, DeepSeek, tools, ops/admin surface) is a separate plan against the same spec. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e645f1b..2e390e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,14 +62,134 @@ importers: specifier: ^8.18.0 version: 8.21.0 + packages/web: + dependencies: + '@gm/protocol': + specifier: workspace:* + version: link:../protocol + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + zustand: + specifier: ^5.0.0 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(tsx@4.22.4)) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(@types/node@22.20.0)(tsx@4.22.4) + packages: + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -88,6 +208,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} @@ -106,6 +232,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} @@ -124,6 +256,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} @@ -142,6 +280,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} @@ -160,6 +304,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} @@ -178,6 +328,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} @@ -196,6 +352,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} @@ -214,6 +376,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} @@ -232,6 +400,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} @@ -250,6 +424,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} @@ -268,6 +448,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} @@ -286,6 +472,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} @@ -304,6 +496,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} @@ -322,6 +520,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} @@ -340,6 +544,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} @@ -358,6 +568,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -370,6 +586,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} @@ -388,6 +610,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -400,6 +628,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} @@ -418,6 +652,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -430,6 +670,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} @@ -448,6 +694,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} @@ -466,6 +718,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} @@ -484,6 +742,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} @@ -502,6 +766,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -551,6 +821,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -568,6 +841,9 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -706,15 +982,41 @@ packages: cpu: [x64] os: [win32] + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -789,10 +1091,20 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + engines: {node: '>=6.0.0'} + hasBin: true + brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -803,6 +1115,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -850,6 +1165,9 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -858,6 +1176,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -882,6 +1203,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + electron-to-chromium@1.5.384: + resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -896,6 +1220,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -969,6 +1298,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1009,12 +1342,25 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -1036,6 +1382,9 @@ packages: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1066,6 +1415,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1153,6 +1506,19 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1210,9 +1576,16 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1381,6 +1754,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1420,6 +1799,46 @@ packages: terser: optional: true + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@2.1.9: resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1478,6 +1897,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -1489,11 +1911,144 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1503,6 +2058,9 @@ snapshots: '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true @@ -1512,6 +2070,9 @@ snapshots: '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.7': optional: true @@ -1521,6 +2082,9 @@ snapshots: '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.7': optional: true @@ -1530,6 +2094,9 @@ snapshots: '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true @@ -1539,6 +2106,9 @@ snapshots: '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true @@ -1548,6 +2118,9 @@ snapshots: '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true @@ -1557,6 +2130,9 @@ snapshots: '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true @@ -1566,6 +2142,9 @@ snapshots: '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true @@ -1575,6 +2154,9 @@ snapshots: '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true @@ -1584,6 +2166,9 @@ snapshots: '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true @@ -1593,6 +2178,9 @@ snapshots: '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true @@ -1602,6 +2190,9 @@ snapshots: '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true @@ -1611,6 +2202,9 @@ snapshots: '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true @@ -1620,6 +2214,9 @@ snapshots: '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true @@ -1629,6 +2226,9 @@ snapshots: '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true @@ -1638,12 +2238,18 @@ snapshots: '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true @@ -1653,12 +2259,18 @@ snapshots: '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true @@ -1668,12 +2280,18 @@ snapshots: '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true @@ -1683,6 +2301,9 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true @@ -1692,6 +2313,9 @@ snapshots: '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true @@ -1701,6 +2325,9 @@ snapshots: '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true @@ -1710,6 +2337,9 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -1774,6 +2404,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1787,6 +2422,8 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -1862,16 +2499,57 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + '@types/estree@1.0.9': {} '@types/node@22.20.0': dependencies: undici-types: 6.21.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.0)(tsx@4.22.4))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(@types/node@22.20.0)(tsx@4.22.4) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1946,10 +2624,20 @@ snapshots: balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.40: {} + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001800 + electron-to-chromium: 1.5.384 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -1957,6 +2645,8 @@ snapshots: cac@6.7.14: {} + caniuse-lite@1.0.30001800: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2007,6 +2697,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + convert-source-map@2.0.0: {} + cookie@1.1.1: {} cross-spawn@7.0.6: @@ -2015,6 +2707,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2032,6 +2726,8 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + electron-to-chromium@1.5.384: {} + emoji-regex@8.0.0: {} end-of-stream@1.4.5: @@ -2066,6 +2762,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2201,6 +2926,8 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} glob@11.1.0: @@ -2236,12 +2963,18 @@ snapshots: joycon@3.1.1: {} + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 json-schema-traverse@1.0.0: {} + json5@2.2.3: {} + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -2258,6 +2991,10 @@ snapshots: lru-cache@11.5.1: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2287,6 +3024,8 @@ snapshots: nanoid@3.3.15: {} + node-releases@2.0.50: {} + object-assign@4.1.1: {} on-exit-leak-free@2.1.2: {} @@ -2361,6 +3100,15 @@ snapshots: quick-format-unescaped@4.0.4: {} + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.7: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -2428,8 +3176,12 @@ snapshots: safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver@6.3.1: {} + semver@7.8.5: {} set-cookie-parser@2.7.2: {} @@ -2575,6 +3327,12 @@ snapshots: undici-types@6.21.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + util-deprecate@1.0.2: {} vite-node@2.1.9(@types/node@22.20.0): @@ -2604,6 +3362,19 @@ snapshots: '@types/node': 22.20.0 fsevents: 2.3.3 + vite@6.4.3(@types/node@22.20.0)(tsx@4.22.4): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + tsx: 4.22.4 + vitest@2.1.9(@types/node@22.20.0): dependencies: '@vitest/expect': 2.1.9 @@ -2660,6 +3431,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -2673,3 +3446,8 @@ snapshots: yargs-parser: 21.1.1 zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7