Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes, newest first. The project follows [Conventional Commits](https://www.conventionalcommits.org/); see `git log` for every commit.

## 2026-10-13: Quick Start, onboarding, journal, spoken subtitles (Day 5)
- **Home** rebuilt: Detective Grace, Quick Start (one press to a room and its QR code), a one-minute "how it works" for first-time TVs, and a How to Play screen.
- **Case library** with filters by age group and story type, and a preview pane.
- **Spoken subtitles:** every in-game line without a recorded take is voiced in the character's voice (server `/speak`, cached clips).
- **Evidence journal:** an open case book with a painted photo per clue. Puzzles sit on a detective's desk. Notices fade by themselves.
- **Phone:** painted suspect portraits, the rainy-street backdrop, evidence as pinned paper slips, parchment replies.
- **World fixes:** corridor and signal box use their painted props; Voss and Ashby stand on the floor; no sky under the platform in any shot.
- Edge TTS client updated (msedge-tts 2.1.0) after Edge began rejecting the old one.

## 2026-10-13: Painted art (Day 4)
- The Last Train is painted: 28 world assets (sky to signal box, the train, Detective Grace, every clue object), placed to line up with what's painted.
- Clue clocks keep exact procedural hands over the painted dials (new manifest `overlay`).
Expand Down
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,13 @@ The AI is an **actor, not a judge**. A pure, deterministic game engine decides e

```mermaid
flowchart LR
Q["📱 'Where were you<br/>at 11:47?'"] --> B["Budget, cooldown,<br/>length checks"]
Q["Phone: Where were you<br/>at 11:47?"] --> B["Budget, cooldown,<br/>length checks"]
B --> F["Truth table:<br/>only unlocked facts"]
F --> M["Free LLM<br/>(Groq gpt-oss-120b)"]
M --> G{"Guard:<br/>spoilers? answers?<br/>out of character?"}
G -- safe --> V["🔊 Voice on the TV<br/>+ subtitles"]
G -- unsafe / offline --> A["Authored fallback line"] --> V
M --> G{"Guard: spoilers,<br/>answers, out of character"}
G -->|"safe"| V["Voice on the TV<br/>+ subtitles"]
G -->|"unsafe or offline"| A["Authored fallback line"]
A --> V
```

- **Locked facts never reach the model.** The prompt holds only the persona, the unlocked facts and the clues already found.
Expand Down Expand Up @@ -168,11 +169,11 @@ bash apps/tv/scripts/wsl-install.sh # install and launch on the Vega Virtual

```mermaid
flowchart LR
subgraph Home["Living room"]
TV["📺 Fire TV · Vega OS<br/>React Native for Vega"]
P["📱 Phones<br/>(browser, no install)"]
subgraph room["Living room"]
TV["Fire TV, Vega OS<br/>React Native for Vega"]
P["Phones<br/>(browser, no install)"]
end
subgraph Server["Game server · Node.js + TypeScript"]
subgraph srv["Game server: Node.js + TypeScript"]
WS["WebSocket gateway"]
GS["Rooms + GameSession"]
ENG["Game engine<br/>(pure, deterministic)"]
Expand All @@ -181,9 +182,11 @@ flowchart LR
end
TV <-->|"intents / state"| WS
P <-->|"intents / state"| WS
WS --> GS --> ENG --> MD
WS --> GS
GS --> ENG
ENG --> MD
GS --> AI
AI -.-> LLM["Groq · Edge TTS<br/>(free tiers)"]
AI -.-> LLM["Groq + Edge TTS<br/>(free tiers)"]
```

**Ground rules**
Expand Down Expand Up @@ -225,7 +228,7 @@ The full technical reference is in **[docs/system-design.md](docs/system-design.

## ✦ Quality

- **About 325 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 78 server, 11 phone and 80 TV.
- **331 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 79 server, 12 phone and 85 TV.
- **Every case is played to the end automatically.** Content validation fails the build on unreachable scenes, broken references, puzzles that need clues from later scenes, and spoilers that could never unlock.
- **Spoiler-attack tests** run against the AI guard and the prompt builder.
- **Scripted device QA:** the Vega Virtual Device is driven with remote key presses and screenshots, with an audio trace sink.
Expand Down
20 changes: 19 additions & 1 deletion apps/player/build.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
// Bundles the phone app into dist/ with esbuild (already in the repo via tsx).
// Usage: node build.mjs [--watch]
import {build, context} from 'esbuild';
import {cpSync, mkdirSync, rmSync, writeFileSync} from 'node:fs';
import {cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync} from 'node:fs';

const outdir = new URL('./dist/', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
rmSync(outdir, {recursive: true, force: true});
mkdirSync(outdir, {recursive: true});
cpSync(new URL('./public/', import.meta.url), outdir, {recursive: true});

// The painted art the TV ships, reused on the phone: suspect portraits (calm)
// and the rainy-street key art as a dimmed backdrop. Missing art just means
// initials, so other cases work before their portraits exist.
const tvAssets = new URL('../tv/src/assets/', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
const portraits = [];
for (const mystery of existsSync(`${tvAssets}mysteries`) ? readdirSync(`${tvAssets}mysteries`) : []) {
const dir = `${tvAssets}mysteries/${mystery}/portraits`;
if (!existsSync(dir)) continue;
mkdirSync(`${outdir}/portraits/${mystery}`, {recursive: true});
for (const file of readdirSync(dir).filter(f => f.endsWith('.calm.png'))) {
const id = file.replace('.calm.png', '');
cpSync(`${dir}/${file}`, `${outdir}/portraits/${mystery}/${id}.png`);
portraits.push(`${mystery}/${id}`);
}
}
if (existsSync(`${tvAssets}ui/home.jpg`)) cpSync(`${tvAssets}ui/home.jpg`, `${outdir}/backdrop.jpg`);

// Design tokens → tokens.css, from the same source as the TV theme.
const tokens = await build({
stdin: {contents: "import {cssVariables} from '@roomriddle/ui'; export default cssVariables();", resolveDir: new URL('.', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), loader: 'ts'},
Expand All @@ -29,6 +46,7 @@ const options = {
minify: !process.argv.includes('--watch'),
sourcemap: true,
logLevel: 'info',
define: {__PORTRAITS__: JSON.stringify(portraits)},
};

if (process.argv.includes('--watch')) {
Expand Down
28 changes: 19 additions & 9 deletions apps/player/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ html, body {
font: var(--t-body-font);
-webkit-font-smoothing: antialiased;
}
/* Atmosphere: two faint light pools and a paper-grain vignette, no images. */
/* Atmosphere: the TV's rainy-street painting, dimmed far down, under a lamp-light pool. */
body::before {
content: "";
position: fixed; inset: 0; z-index: -1; pointer-events: none;
background:
radial-gradient(120% 60% at 85% -10%, var(--c-accent-wash), transparent 60%),
radial-gradient(90% 50% at -10% 110%, var(--c-signal-wash), transparent 60%),
linear-gradient(var(--c-background), var(--c-veil));
linear-gradient(color-mix(in srgb, var(--c-background) 86%, transparent), var(--c-background) 70%),
url("backdrop.jpg") center top / cover no-repeat,
var(--c-background);
}
main {
max-width: 560px; margin: 0 auto;
Expand Down Expand Up @@ -151,9 +152,14 @@ button.option.pending { opacity: .6; }
.tabs button.on { color: var(--c-accent); border-bottom-color: var(--c-accent); }
.tabs .mono { color: var(--c-text-muted); }

.evidence { margin-top: var(--s-sm); padding: var(--s-lg); background: var(--c-surface); border: 1px solid var(--c-border); border-top: 3px solid var(--c-accent-soft); border-radius: var(--r-sm); box-shadow: var(--shadow-card); animation: reveal var(--m-discovery) both; }
.evidence.solved { border-top-color: var(--c-success); }
.evidence header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--s-xs); font-size: 12px; color: var(--c-text-muted); }
/* Evidence is a paper slip from the case journal: ink on paper, a pin, a slight hand-placed tilt. */
.evidence { position: relative; margin-top: var(--s-md); padding: var(--s-lg); background: var(--c-paper); color: var(--c-ink); border: 0; border-radius: 2px; box-shadow: var(--shadow-card); animation: reveal var(--m-discovery) both; }
.evidence:nth-of-type(odd) { rotate: -0.6deg; } .evidence:nth-of-type(even) { rotate: 0.5deg; }
.evidence::before { content: ""; position: absolute; top: -6px; left: 50%; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #a3301f; box-shadow: 0 2px 3px rgba(0,0,0,.4); }
.evidence.solved::before { background: var(--c-success); }
.story .evidence p, .evidence p, .evidence h4 { color: var(--c-ink); }
.evidence .tag.ok, .evidence .muted { color: color-mix(in srgb, var(--c-ink) 65%, transparent); }
.evidence header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--s-xs); font-size: 12px; color: #8a2c1c; }
.evidence h4 { margin-bottom: var(--s-xs); }
.tag { font: var(--t-label-font); letter-spacing: 1.5px; text-transform: uppercase; }
.tag.ok { color: var(--c-success); }
Expand All @@ -178,15 +184,19 @@ button.option.pending { opacity: .6; }
.cast-text { display: grid; gap: 2px; }
.cast-text b { font: var(--t-h2-font); font-size: 18px; }
.cast-text .muted { font-size: 13px; }
.avatar { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--c-accent-soft); background: var(--c-surface-sunken); color: var(--c-accent); font: var(--t-label-font); letter-spacing: 1px; }
.avatar.big { width: 64px; height: 64px; font-size: 18px; }
.avatar { position: relative; display: grid; place-items: center; width: 52px; height: 52px; overflow: hidden; border-radius: 50%; border: 2px solid var(--c-accent-soft); background: var(--c-surface-sunken); color: var(--c-accent); font: var(--t-label-font); letter-spacing: 1px; }
.avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.avatar.big { width: 88px; height: 88px; font-size: 20px; border-color: var(--c-accent); }
.cast-head { display: flex; gap: var(--s-md); align-items: center; margin: var(--s-sm) 0 var(--s-lg); }
.cast-head .display { margin: 0; font-size: 26px; line-height: 32px; }
.thread { list-style: none; margin: 0 0 var(--s-lg); padding: 0; display: grid; gap: var(--s-sm); }
.thread li { max-width: 88%; padding: var(--s-sm) var(--s-md); border-radius: var(--r-md); animation: reveal var(--m-discovery) both; }
.thread .q { justify-self: end; background: var(--c-surface-elevated); border: 1px solid var(--c-border); }
.thread .q .label { font-size: 10px; margin-bottom: 2px; }
.thread .a { justify-self: start; background: var(--c-surface); border-left: 3px solid var(--c-accent); font: var(--t-body-font); font-style: italic; }
/* Replies look like the TV's speech bubble: parchment, ink, a tail toward the speaker. */
.thread .a { position: relative; justify-self: start; background: var(--c-paper); color: var(--c-ink); border: 1px solid var(--c-accent-soft); font: var(--t-narration-font); font-size: 17px; line-height: 25px; }
.thread .a::before { content: ""; position: absolute; left: 14px; top: -7px; width: 12px; height: 12px; background: var(--c-paper); border-left: 1px solid var(--c-accent-soft); border-top: 1px solid var(--c-accent-soft); transform: rotate(45deg); }
.thread .a .dots i { background: var(--c-ink); }
.thread .empty-thread { justify-self: center; background: none; font-size: 14px; }
.dots { display: inline-flex; gap: 5px; padding: 4px 0; }
.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--c-accent); animation: pulse 700ms infinite alternate; }
Expand Down
12 changes: 11 additions & 1 deletion apps/player/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import {initialRoomView, type RoomView} from '@roomriddle/client';
import {createInitialGameState} from '@roomriddle/game-engine';
import type {CharacterReply, PublicMystery, Room} from '@roomriddle/shared-types';
import {errorState, initialUi, loadingState, renderScreen, screenName} from './render';
import {avatar, errorState, initialUi, loadingState, renderScreen, screenName} from './render';

const room = (over: Partial<Room> = {}): Room => ({
id: 'r',
Expand Down Expand Up @@ -133,3 +133,13 @@ test('with no questions left the form is closed', () => {
assert.match(html, /No questions left/);
assert.match(html, /<textarea[^>]*disabled/);
});

test('suspects show their painted portrait when the case has one, initials otherwise', () => {
const art = new Set(['the-last-train/voss']);
const voss = avatar('the-last-train', {id: 'voss', name: 'Inspector Voss'}, true, art);
assert.match(voss, /<img src="\/portraits\/the-last-train\/voss.png" alt=""/);
assert.match(voss, /avatar big painted/);
const ned = avatar('the-last-train', {id: 'driver', name: 'Ned Hollis'}, false, art);
assert.doesNotMatch(ned, /<img/);
assert.match(ned, />NH</);
});
15 changes: 13 additions & 2 deletions apps/player/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,17 @@ function initials(name: string): string {
return name.replace(/^(Inspector|Detective|The)\s+/i, '').split(/\s+/).map(w => w[0] ?? '').join('').slice(0, 2).toUpperCase();
}

// "mysteryId/characterId" for every painted portrait, injected by build.mjs (empty in tests).
declare const __PORTRAITS__: string[] | undefined;
const PORTRAITS = new Set<string>(typeof __PORTRAITS__ !== 'undefined' ? __PORTRAITS__ : []);

/** The painted portrait when the case has one, else initials on a disc. */
export function avatar(mysteryId: string, c: {id: string; name: string}, big = false, portraits: ReadonlySet<string> = PORTRAITS): string {
const key = `${mysteryId}/${c.id}`;
const art = portraits.has(key) ? `<img src="/portraits/${esc(key)}.png" alt="" loading="lazy" />` : '';
return `<span class="avatar${big ? ' big' : ''}${art ? ' painted' : ''}" aria-hidden="true">${art || esc(initials(c.name))}</span>`;
}

/** Pick a person, ask in your own words, optionally show a found clue. Replies come from the server. */
export function suspectsView(view: RoomView, ui: UiState): string {
const mystery = view.mystery!;
Expand All @@ -369,7 +380,7 @@ export function suspectsView(view: RoomView, ui: UiState): string {
.map(c => {
const asked = view.conversation.filter(r => r.characterId === c.id).length;
return `<li><button class="cast-card" data-suspect="${esc(c.id)}">
<span class="avatar" aria-hidden="true">${esc(initials(c.name))}</span>
${avatar(mystery.summary.id, c)}
<span class="cast-text"><b>${esc(c.name)}</b><span class="muted">${esc(c.role)}</span></span>
${asked ? `<span class="mono muted">${asked}</span>` : ''}<span class="pc-arrow" aria-hidden="true">→</span>
</button></li>`;
Expand All @@ -387,7 +398,7 @@ export function suspectsView(view: RoomView, ui: UiState): string {
return `<section class="interview">
<button id="suspects-back" class="link">← Everyone</button>
<div class="cast-head">
<span class="avatar big" aria-hidden="true">${esc(initials(suspect.name))}</span>
${avatar(mystery.summary.id, suspect, true)}
<div><h1 class="display">${esc(suspect.name)}</h1><p class="muted">${esc(suspect.role)}</p></div>
</div>
<ol class="thread" aria-live="polite">${thread
Expand Down
4 changes: 2 additions & 2 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
"dependencies": {
"@roomriddle/client": "*",
"@roomriddle/game-engine": "*",
"@roomriddle/scene": "*",
"@roomriddle/mystery-data": "*",
"@roomriddle/scene": "*",
"@roomriddle/shared-types": "*",
"msedge-tts": "^2.0.9",
"msedge-tts": "^2.1.0",
"qrcode-generator": "^2.0.4",
"ws": "^8.22.0"
},
Expand Down
4 changes: 2 additions & 2 deletions apps/server/scripts/ask-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ function phone(name: string, onMessage: (m: ServerMessage, ws: WebSocket) => voi
}

let asked = false;
phone('Mira', () => {});
phone('Aksh', (m, ws) => {
phone(process.env.ASK_NAME2 ?? 'Mira', () => {});
phone(process.env.ASK_NAME ?? 'Aksh', (m, ws) => {
if (m.type === 'ERROR') console.log(`[bot] ERROR ${m.code}: ${m.message}`);
if ((m.type === 'GAME_STARTED' || (m.type === 'ROOM_JOINED' && m.room.status === 'playing')) && !asked) {
asked = true;
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,21 @@ test('serves spoken replies by id, immutable, and 404s unknown or malformed ids'
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

test('speaks subtitle lines: validates input and redirects to the clip', async () => {
const asked: string[] = [];
const server = createAppServer({ playerDir: '/nonexistent', speak: async (text, voice) => (asked.push(`${voice.edge}|${voice.rate ?? ''}|${text}`), 'abcdef0123456789abcd') });
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const base = `http://127.0.0.1:${port}`;
try {
const ok = await fetch(`${base}/speak?text=${encodeURIComponent('The platform clock.')}&voice=en-GB-SoniaNeural&rate=-4%25`, { redirect: 'manual' });
assert.equal(ok.status, 302);
assert.equal(ok.headers.get('location'), '/voice/abcdef0123456789abcd.m4a');
assert.deepEqual(asked, ['en-GB-SoniaNeural|-4%|The platform clock.']);
assert.equal((await fetch(`${base}/speak?text=hi&voice=evil`)).status, 400);
assert.equal((await fetch(`${base}/speak?text=${'a'.repeat(401)}&voice=en-GB-SoniaNeural`)).status, 400);
} finally {
server.close();
}
});
22 changes: 22 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const MIME: Record<string, string> = {
'.webmanifest': 'application/manifest+json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
};

Expand All @@ -27,6 +28,8 @@ export interface AppServerOptions {
stats?: () => {rooms: number; connections: number};
/** Spoken replies, served at /voice/<id>. */
voiceClip?: VoiceClipLookup;
/** Speaks an authored line (subtitles) and resolves to its clip id, or null. */
speak?: (text: string, voice: {edge: string; rate?: string; pitch?: string}) => Promise<string | null>;
}

/**
Expand Down Expand Up @@ -78,6 +81,25 @@ export function createAppServer(options: AppServerOptions = {}): Server {
res.end(req.method === 'HEAD' ? undefined : clip.data);
return;
}
// Spoken subtitles: /speak?text=…&voice=en-GB-SoniaNeural[&rate=-4%&pitch=-8%] → a 302 to its /voice clip.
if (url.pathname === '/speak' && options.speak) {
const text = (url.searchParams.get('text') ?? '').trim();
const voice = url.searchParams.get('voice') ?? '';
if (!text || text.length > 400 || !/^[a-z]{2}-[A-Z]{2}-[A-Za-z]+Neural$/.test(voice)) {
sendJson(res, 400, {error: 'bad_request'});
return;
}
const tone = (v: string | null) => (v && /^[+-]\d{1,2}%$/.test(v) ? v : undefined);
options
.speak(text, {edge: voice, rate: tone(url.searchParams.get('rate')), pitch: tone(url.searchParams.get('pitch'))})
.then(id => {
if (!id) return sendJson(res, 503, {error: 'voice_unavailable'});
res.writeHead(302, {location: `/voice/${id}.m4a`, 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400'});
res.end();
})
.catch(() => sendJson(res, 503, {error: 'voice_unavailable'}));
return;
}
if (servePlayerApp(res, playerDir, url.pathname)) return;
sendJson(res, 404, {error: 'not_found'});
});
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const server = createAppServer({stats: () => ({rooms: rooms.size, connections: s
const clip = voices.clip(id);
log(`[voice] GET ${id} ${clip ? "200" : "404"}`);
return clip;
}});
}, speak: voices.enabled ? (text, voice) => voices.speak(text, {groq: 'diana', ...voice}) : undefined});
const sockets = attachGameSocket(server, rooms, {log, characters, voices});
socketCount = () => sockets.connections.count;

Expand Down
Loading
Loading