Compare commits

...
15 Commits
Author SHA1 Message Date
hypercross f01bb5f99b feat(web): rework mod inspector layout
Fill the viewport with a fixed sidebar of highlightable type-filter
icons over an independently scrolling tree, and let the viewer expand
into a full-height scene.
2026-08-14 11:27:46 +08:00
hypercross 8ebb9211c5 fix(web): frame deck carousel on active card
Fit the camera in a frame callback so the active card has settled into
its arc slot first, and aim it straight at the card's front face instead
of keeping the initial side angle. Measure the card's width and widen the
arc radius so wide cards no longer clip their neighbors.
2026-08-14 11:15:59 +08:00
hypercross a3095ead38 feat(web): move mod header into site header
Replace the global TTS Workshop header on mod routes with a ModHeader showing the thumbnail, game name, and download/setup links styled like the Search/BGM nav.
2026-08-14 10:59:20 +08:00
hypercross 30ef76632f feat: return workshop metadata with mod details
Include the Workshop title and preview image in the items response so the mod header survives a page refresh, not just navigation from search. Resolve both from the same Steam API call.
2026-08-14 10:59:14 +08:00
hypercross 7163451d1f fix(web): declare card shader UV uniforms
three.js does not auto-declare uniforms added via onBeforeCompile, so the
injected uMapRepeat/uMapOffset were undeclared and the card shader failed
to compile, rendering cards transparent. Prepend the declarations to the
vertex shader.
2026-08-14 10:52:54 +08:00
hypercross 2430be9661 feat(web): rework mod page header layout
Show the game name and thumbnail in the header, float the action buttons and object GUID to the right, and keep the viewer icon, label, and GUID on one line.
2026-08-14 10:46:27 +08:00
hypercross 002bcb324b perf(web): share card texture via shader UV transform
Move the card sprite repeat/offset out of per-card texture clones and
into a uniform injected into the material shader. Cards now share the
deck sheet (one GPU upload), the shader, and the geometry, with only
per-card material uniforms differing, so navigating a deck no longer
re-uploads the sheet on every step.
2026-08-14 10:46:09 +08:00
hypercross c665212209 feat(web): add deck viewer
Add a DeckViewer that frames the active card in a deck carousel. Scene gains a fit prop so the shared bounds behavior can be disabled and refit manually.
2026-08-14 10:29:04 +08:00
hypercross 2403abc07b feat(web): add type filter bar to object tree
Chips highlight object types to show only those in the tree; with none highlighted, everything is shown. Filtered nodes keep their original index path so selection stays stable.
2026-08-14 10:29:01 +08:00
hypercross ee7e73c798 style(web): use 3:4 aspect ratio for viewers 2026-08-14 09:54:52 +08:00
hypercross dd08f901ae feat: add inline PDF viewer for Custom_PDF objects
Add a /pdf proxy route that forces Content-Disposition inline so PDFs render instead of downloading, and a web viewer that embeds the document in an iframe with a standalone open-in-new-tab link.
2026-08-14 09:51:11 +08:00
hypercross 8bd186df54 refactor(engine): unify commands and orchestrators
Replace the CommandHost with a handler registry keyed by message type.
Message is a permissive base type the host extends; CommandResult is a
discriminated union on the type suffix. Commands and orchestrators are
the same async shape taking a RunContext with an AbortSignal; runCommand
emits :done/:cancel/:error.
2026-08-10 18:40:35 +08:00
hypercross 81c115cb4d docs: unify commands and orchestrators in bgm-engine
Messages are a discriminated union with generic command/result types;
the command host is dropped for a handler registry; commands and
orchestrators are the same async shape taking a cancellable RunContext.
2026-08-10 18:40:30 +08:00
hypercross 3167d26bd6 feat(engine): add message queue, triggers, and orchestrators
New @tts/engine package: the pure message layer that drives bgm games.
MessageQueue snapshots and drains on tick; TriggerRegistry matches by
type/id with runtime enable/disable; runOrchestrator suspends on wait
until a matching message is processed. Engine ties them together behind
the CommandHost contract that @tts/tabletop will implement.
2026-08-10 18:26:23 +08:00
hypercross 91cd3a16d7 docs: add bgm-engine message layer design
Define the message/queue/tick model, the three handler kinds (trigger,
orchestrator, command host), and the @tts/engine package split. Cross-link
from bgm-commands and bgm-tabletop, and add the engine to the architecture
package table.
2026-08-10 18:26:19 +08:00
46 changed files with 1957 additions and 227 deletions
+2
View File
@@ -8,6 +8,7 @@ import { setGamesRoot } from './config.js';
import asset from './routes/asset.js'; import asset from './routes/asset.js';
import health from './routes/health.js'; import health from './routes/health.js';
import items from './routes/items.js'; import items from './routes/items.js';
import pdf from './routes/pdf.js';
import search from './routes/search.js'; import search from './routes/search.js';
import trace from './routes/trace.js'; import trace from './routes/trace.js';
@@ -27,6 +28,7 @@ app.route('/health', health);
app.route('/search', search); app.route('/search', search);
app.route('/items', items); app.route('/items', items);
app.route('/asset', asset); app.route('/asset', asset);
app.route('/pdf', pdf);
app.route('/trace', trace); app.route('/trace', trace);
serve( serve(
+20 -17
View File
@@ -1,10 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts'; import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
import type { TTSMod } from '@tts/shared'; import type { ModDetails } from '@tts/shared';
import items from './items.js'; import items from './items.js';
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 }; const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
const mod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const details: ModDetails = { mod };
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -17,7 +25,7 @@ describe('items route', () => {
}); });
it('returns 500 when the API key is missing and no fileUrl is given', async () => { it('returns 500 when the API key is missing and no fileUrl is given', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new TtsError('STEAM_API_KEY is not configured', 500), new TtsError('STEAM_API_KEY is not configured', 500),
); );
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 }); const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
@@ -26,11 +34,6 @@ describe('items route', () => {
}); });
it('loads from a fileUrl without an API key', async () => { it('loads from a fileUrl without an API key', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const fetchModFromUrl = vi const fetchModFromUrl = vi
.spyOn(await import('@tts/tts'), 'fetchModFromUrl') .spyOn(await import('@tts/tts'), 'fetchModFromUrl')
.mockResolvedValue(mod); .mockResolvedValue(mod);
@@ -40,26 +43,26 @@ describe('items route', () => {
{ STEAM_API_KEY: '', PORT: 3000 }, { STEAM_API_KEY: '', PORT: 3000 },
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.json()).toEqual(mod); expect(await res.json()).toEqual(details);
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json'); expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
}); });
it('returns the parsed mod on success', async () => { it('returns the parsed mod with metadata on success', async () => {
const mod: TTSMod = { const withMeta: ModDetails = {
GameMode: 'Tabletop', mod,
Date: '2024-01-01', title: 'My Mod',
ObjectStates: [], previewImageUrl: 'https://example.com/preview.png',
}; };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}'))); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod); vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
const res = await items.request('/123', {}, env); const res = await items.request('/123', {}, env);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.json()).toEqual(mod); expect(await res.json()).toEqual(withMeta);
}); });
it('maps TtsError subclasses to their status', async () => { it('maps TtsError subclasses to their status', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new ItemNotFoundError('123'), new ItemNotFoundError('123'),
); );
const res = await items.request('/123', {}, env); const res = await items.request('/123', {}, env);
@@ -70,7 +73,7 @@ describe('items route', () => {
}); });
it('returns 500 for unexpected errors', async () => { it('returns 500 for unexpected errors', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new Error('boom'), new Error('boom'),
); );
const res = await items.request('/123', {}, env); const res = await items.request('/123', {}, env);
+8 -7
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { itemIdSchema, type TTSMod } from '@tts/shared'; import { itemIdSchema, type ModDetails } from '@tts/shared';
import { import {
fetchMod, fetchModDetails,
fetchModFile, fetchModFile,
fetchModFileFromUrl, fetchModFileFromUrl,
fetchModFromUrl, fetchModFromUrl,
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
try { try {
const fileUrl = c.req.query('fileUrl'); const fileUrl = c.req.query('fileUrl');
// With no Steam API key, the caller must supply the save URL (e.g. from a // With no Steam API key, the caller must supply the save URL (e.g. from a
// search result). Otherwise resolve it via the Steam API. // search result). Otherwise resolve it via the Steam API, which also
const mod: TTSMod = fileUrl // yields the Workshop title and preview image.
? await fetchModFromUrl(fileUrl) const details: ModDetails = fileUrl
: await fetchMod(id, c.env.STEAM_API_KEY ?? ''); ? { mod: await fetchModFromUrl(fileUrl) }
return c.json<TTSMod>(mod); : await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
return c.json<ModDetails>(details);
} catch (err) { } catch (err) {
if (err instanceof TtsError) { if (err instanceof TtsError) {
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502); return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import pdf from './pdf.js';
import { setGamesRoot } from '../config.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
setGamesRoot(dir);
});
afterEach(() => {
vi.unstubAllGlobals();
setGamesRoot(undefined);
});
describe('pdf route', () => {
it('rejects a missing url', async () => {
const res = await pdf.request('/');
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'Missing url query param' });
});
it('rejects a non-http url', async () => {
const res = await pdf.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
expect(res.status).toBe(400);
});
it('streams the fetched pdf inline with a pdf content-type', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'application/pdf' },
}),
),
);
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/pdf');
expect(res.headers.get('content-disposition')).toBe('inline');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
it('returns 502 when the upstream fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
);
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
expect(res.status).toBe(502);
});
it('serves a local game asset from GAMES_ROOT', async () => {
writeFileSync(path.join(dir, 'rules.pdf'), new Uint8Array([1, 2, 3]));
const res = await pdf.request('/?url=rules.pdf');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/pdf');
expect(res.headers.get('content-disposition')).toBe('inline');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { Hono } from 'hono';
import { GAMES_ROOT } from '../config.js';
import { resolveAsset } from './resolveAsset.js';
const app = new Hono();
/**
* Fetch a PDF and stream it back inline so the browser renders it instead of
* downloading. PDF hosts often send `Content-Disposition: attachment`, which
* forces a download even when the URL is opened in a tab or iframe. This route
* strips that header and forces `inline` so the PDF can be embedded in the web
* app's viewer. Like `/asset`, a relative URL (no scheme) is treated as a game
* asset path relative to `GAMES_ROOT`.
*/
app.get('/', async (c) => {
const raw = c.req.query('url');
if (!raw) {
return c.json({ error: 'Missing url query param' }, 400);
}
const result = await resolveAsset(raw, GAMES_ROOT);
if (!result.ok) {
return c.json(
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
result.reason === 'not-found' ? 404 : 400,
);
}
const asset = result.asset;
// A local game asset is streamed from disk; a remote one is fetched.
let body: BodyInit;
if (asset.stream) {
body = asset.stream as unknown as BodyInit;
} else {
let res: Response;
try {
res = await fetch(asset.url!);
} catch (err) {
return c.json({ error: `Failed to fetch pdf: ${String(err)}` }, 502);
}
if (!res.ok) {
return c.json({ error: `Pdf responded ${res.status}` }, 502);
}
body = res.body!;
}
return new Response(body, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline',
'Cache-Control': 'public, max-age=86400',
},
});
});
export default app;
+29 -15
View File
@@ -1,7 +1,8 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Link, Route, Routes } from 'react-router-dom'; import { Link, Route, Routes, useLocation } from 'react-router-dom';
import SearchPage from './pages/SearchPage'; import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage'; import ModPage from './pages/ModPage';
import ModHeader from './components/ModHeader';
// The full-setup view and the bgm/tabletop pages pull in the whole three.js // The full-setup view and the bgm/tabletop pages pull in the whole three.js
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that // stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
@@ -18,24 +19,36 @@ const SetupsPage = lazy(() => import('./pages/SetupsPage'));
const SetupPage = lazy(() => import('./pages/SetupPage')); const SetupPage = lazy(() => import('./pages/SetupPage'));
export default function App() { export default function App() {
const pathname = useLocation().pathname;
const isModRoute = pathname.startsWith('/mod/');
// The inspector view is full-bleed/viewport-height; the setup sub-route keeps the standard centered layout.
const isModView = /^\/mod\/[^/]+$/.test(pathname);
return ( return (
<div className="min-h-screen bg-zinc-950 text-zinc-100"> <div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800"> {/* For the mod route the layout is full-bleed and fills the viewport so
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4"> the sidebar can scroll independently and the viewer gets all the space. */}
<Link to="/" className="text-lg font-semibold tracking-tight"> <div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
TTS Workshop <header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
</Link> {isModRoute ? (
<nav className="flex gap-4 text-sm text-zinc-400"> <ModHeader />
<Link to="/" className="hover:text-zinc-100"> ) : (
Search <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<Link to="/" className="text-lg font-semibold tracking-tight">
TTS Workshop
</Link> </Link>
<Link to="/bgm" className="hover:text-zinc-100"> <nav className="flex gap-4 text-sm text-zinc-400">
BGM <Link to="/" className="hover:text-zinc-100">
</Link> Search
</nav> </Link>
</div> <Link to="/bgm" className="hover:text-zinc-100">
BGM
</Link>
</nav>
</div>
)}
</header> </header>
<main className="mx-auto max-w-5xl px-4 py-8"> <main className={(isModView ? "min-h-0 flex-1" : "mx-auto max-w-5xl px-4 py-8")}>
<Routes> <Routes>
<Route path="/" element={<SearchPage />} /> <Route path="/" element={<SearchPage />} />
<Route path="/mod/:id" element={<ModPage />} /> <Route path="/mod/:id" element={<ModPage />} />
@@ -57,6 +70,7 @@ export default function App() {
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} /> <Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
</Routes> </Routes>
</main> </main>
</div>
</div> </div>
); );
} }
+4 -4
View File
@@ -1,4 +1,4 @@
import type { SearchResult, TTSMod } from '@tts/shared'; import type { ModDetails, SearchResult } from '@tts/shared';
import { traceImage } from '@tts/http'; import { traceImage } from '@tts/http';
export { traceImage }; export { traceImage };
@@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
} }
/** /**
* Fetch a full parsed TTS save. * Fetch a full parsed TTS save plus its Workshop metadata.
*/ */
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> { export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (fileUrl) params.set('fileUrl', fileUrl); if (fileUrl) params.set('fileUrl', fileUrl);
const qs = params.toString(); const qs = params.toString();
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`); return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
} }
/** Build a URL for the raw save file download. */ /** Build a URL for the raw save file download. */
+59
View File
@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
/**
* Replaces the global site header on the mod page: thumbnail, game name, and
* a subheader with the mod id and stats, plus download/setup links styled like
* the Search/BGM nav.
*/
export default function ModHeader() {
const { id } = useParams<{ id: string }>();
const { mod } = useModStore();
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
// Prefer the metadata fetched with the save (survives a refresh); fall back
// to the search result for the brief moment before the save loads.
const title = mod?.title ?? item?.title;
const previewImageUrl = mod?.previewImageUrl ?? item?.previewImageUrl;
return (
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4">
<div className="flex min-w-0 items-center gap-3">
{previewImageUrl && (
<img
src={previewImageUrl}
alt={title}
className="h-12 w-12 shrink-0 rounded-lg border border-zinc-800 object-cover"
/>
)}
<div className="min-w-0">
<h1 className="truncate text-lg font-semibold tracking-tight">
{title ?? `Mod ${id}`}
</h1>
{mod && (
<p className="truncate text-sm text-zinc-400">
<span className="font-mono">{id}</span> · {mod.mod.GameMode} ·{' '}
{mod.mod.Date} · {tree.length} objects · {refs.length} asset refs
</p>
)}
</div>
</div>
<nav className="flex shrink-0 gap-4 text-sm text-zinc-400">
<a
href={modFileUrl(id!, item?.fileUrl)}
className="hover:text-zinc-100"
>
Download
</a>
<Link to={`/mod/${id}/setup`} className="hover:text-zinc-100">
Setup
</Link>
</nav>
</div>
);
}
+126 -13
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import type { ObjectTreeNode } from '@tts/extract'; import type { ObjectTreeNode } from '@tts/extract';
import { iconsForObject } from './objectIcons'; import { iconsForObject } from './objectIcons';
@@ -10,19 +10,85 @@ interface Props {
} }
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) { export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
const [highlighted, setHighlighted] = useState<Set<string>>(() => new Set());
const types = useMemo(() => collectTypes(nodes), [nodes]);
// When nothing is highlighted, every type is shown. Otherwise only the
// highlighted types are visible.
const visibleNodes = useMemo(
() => filterTree(nodes, highlighted),
[nodes, highlighted],
);
const toggleType = (name: string) =>
setHighlighted((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
const clearAll = () => setHighlighted(new Set());
return ( return (
<ul className="space-y-0.5"> <div className="flex h-full min-h-0 flex-col">
{nodes.map((node, index) => ( {types.length > 0 && (
<TreeNode <div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
key={index} <div className="grid grid-cols-6 gap-1">
node={node} {types.map(({ name, count }) => {
depth={0} const active = highlighted.has(name);
path={`${index}`} return (
selectedPath={selectedPath} <button
onSelect={onSelect} key={name}
/> onClick={() => toggleType(name)}
))} aria-pressed={active}
</ul> title={`${name} (${count}) — ${active ? 'unhighlight' : 'highlight'}`}
className={`relative flex h-8 items-center justify-center rounded-md border transition-colors ${
active
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
}`}
>
{iconsForObject(name).map((icon) => (
<Icon key={icon} icon={icon} className="h-4 w-4" />
))}
<span
className={`absolute -bottom-1 -right-1 rounded bg-zinc-950 px-0.5 font-mono text-[9px] leading-tight ${
active ? 'text-zinc-200' : 'text-zinc-500'
}`}
>
{count}
</span>
</button>
);
})}
</div>
{highlighted.size > 0 && (
<div className="mt-1.5 flex justify-end">
<button
onClick={clearAll}
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
>
Clear
</button>
</div>
)}
</div>
)}
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-3 pt-2">
{visibleNodes.map(({ node, path }) => (
<TreeNode
key={path}
node={node}
depth={0}
path={path}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</ul>
</div>
); );
} }
@@ -102,4 +168,51 @@ function TreeNode({
)} )}
</li> </li>
); );
}
/** Distinct object types present in the tree, with a count of each. */
function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] {
const counts = new Map<string, number>();
const visit = (node: ObjectTreeNode) => {
counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1);
node.children.forEach(visit);
};
nodes.forEach(visit);
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Keep a node when its type is highlighted or any descendant survives, so the
* containment hierarchy is preserved and highlighted parents still lead to
* highlighted children. With nothing highlighted, every node is kept.
*
* Each result carries the node's original index path into the full (unfiltered)
* tree, so selection stays stable regardless of filtering.
*/
function filterTree(
nodes: ObjectTreeNode[],
highlighted: Set<string>,
prefix = '',
): { node: ObjectTreeNode; path: string }[] {
const result: { node: ObjectTreeNode; path: string }[] = [];
nodes.forEach((node, index) => {
const path = prefix ? `${prefix}-${index}` : `${index}`;
const children = filterTree(node.children, highlighted, path);
const visible =
highlighted.size === 0 ||
highlighted.has(node.object.Name) ||
children.length > 0;
if (visible) {
result.push({
node:
children.length > 0
? { ...node, children: children.map((c) => c.node) }
: node,
path,
});
}
});
return result;
} }
+9 -2
View File
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
export interface ObjectViewer { export interface ObjectViewer {
/** The object class this viewer handles, e.g. `Card`, `Bag`. */ /** The object class this viewer handles, e.g. `Card`, `Bag`. */
name: string; name: string;
component: (props: { object: TTSObject }) => ReactNode; component: (props: ViewerProps) => ReactNode;
}
/** Props passed to every object viewer. */
export interface ViewerProps {
object: TTSObject;
/** Expand the 3D scene to fill its container instead of the default frame. */
fill?: boolean;
} }
const registry = new Map<string, ObjectViewer['component']>(); const registry = new Map<string, ObjectViewer['component']>();
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
} }
/** The default viewer: a plain inspection of the object's fields. */ /** The default viewer: a plain inspection of the object's fields. */
export function DefaultViewer({ object }: { object: TTSObject }) { export function DefaultViewer({ object }: ViewerProps) {
return ( return (
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2"> <dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
{Object.entries(object).map(([key, value]) => { {Object.entries(object).map(([key, value]) => {
+60 -44
View File
@@ -9,8 +9,14 @@ import {
} from '@tts/mesh'; } from '@tts/mesh';
import { assetUrl } from '@tts/http'; import { assetUrl } from '@tts/http';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture'; import { applyMapTransform } from './cardMaterial';
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources'; import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
/** Longer card dimension, in world units. */ /** Longer card dimension, in world units. */
const CARD_LENGTH = 2; const CARD_LENGTH = 2;
@@ -72,37 +78,61 @@ export function CardMesh({
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL); const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL); const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
// Front texture: the sprite cell from the sheet (or the full image when there // The face/back textures are shared (drei caches them by URL); each card's
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards // sprite cell is selected via a per-material UV transform injected into the
// that share the same sheet URL (drei caches textures globally by URL). // shader, so no per-card texture clone (and no re-upload) is needed. The
const faceMap = useMemo(() => { // transform is baked into the material's shader, so it must be keyed into the
if (!faceUrl) return null; // shared-material cache to avoid mutating a material used by another card.
const tex = face.clone(); const faceMap = faceUrl ? face : null;
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight); const backMap = backUrl ? back : null;
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return tex;
}, [faceUrl, face, cardId, numWidth, numHeight]);
// Back texture: a single full image (tile) unless the deck has unique backs, const tintK = tintKey(tint);
// in which case it's a sheet too. Flipped left/right so it reads correctly const faceUv = faceUrl ? spriteUv(cardId, numWidth, numHeight) : null;
// instead of being mirrored on the back face. const backUv = backUrl
const backMap = useMemo(() => { ? uniqueBack
if (!backUrl) return null;
const tex = back.clone();
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
? spriteUv(cardId, numWidth, numHeight) ? spriteUv(cardId, numWidth, numHeight)
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }; : { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
tex.repeat.set(repeatX, repeatY); : null;
tex.offset.set(offsetX, offsetY);
return flipTexture(tex); // Key by URL + card id + tint: the URL disambiguates different sheets (and
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]); // `CardCustom` objects, which have no `CardID`), the card id selects the
// sprite cell, and the tint bakes the per-object color in.
const faceMat = getSharedMaterial(`card-face:${faceUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
color: tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: faceMap ?? undefined,
roughness: 0.6,
});
if (faceUv) {
applyMapTransform(faceMat, new THREE.Vector2(faceUv.repeatX, faceUv.repeatY), new THREE.Vector2(faceUv.offsetX, faceUv.offsetY));
}
const backMat = getSharedMaterial(`card-back:${backUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
color: tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: backMap ?? undefined,
roughness: 0.6,
});
if (backUv) {
// The back cap maps with the same planar UVs as the front, so mirror the
// sprite cell left/right to read correctly instead of appearing mirrored.
// Negating repeat.x and shifting offset.x by one repeat keeps the visible
// region in place while mirrored (see `flipTexture`).
applyMapTransform(
backMat,
new THREE.Vector2(-backUv.repeatX, backUv.repeatY),
new THREE.Vector2(backUv.offsetX + backUv.repeatX, backUv.offsetY),
);
}
const wallMat = getSharedMaterial(`card-wall:${tintK}`, {
color: tintedColor(new THREE.Color('#ffffff'), tint),
roughness: 0.6,
});
// Build the rounded-rect geometry from the card sprite's aspect ratio. The // Build the rounded-rect geometry from the card sprite's aspect ratio. The
// front and back faces each get their own material; the walls are a solid // front and back faces each get their own material; the walls are a solid
// white, matching TTS card tinting. Geometry is shared across cards of the // white, matching TTS card tinting. Geometry is shared across cards of the
// same size so the full-setup view reuses it; the face/back materials stay // same size so the full-setup view reuses it; the face/back materials are
// per-card because each card clones its texture for sprite UVs. // shared per card (keyed by card id + tint) and carry the sprite UV transform.
const { frontGeo, backGeo, wallsGeo } = useMemo(() => { const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement | HTMLImageElement
@@ -124,23 +154,9 @@ export function CardMesh({
return ( return (
<group> <group>
<mesh geometry={frontGeo}> <mesh geometry={frontGeo} material={faceMat} />
<meshStandardMaterial <mesh geometry={backGeo} material={backMat} />
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)} <mesh geometry={wallsGeo} material={wallMat} />
map={faceMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={backGeo}>
<meshStandardMaterial
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
map={backMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={wallsGeo}>
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
</mesh>
</group> </group>
); );
} }
@@ -1,6 +1,7 @@
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import Scene from './Scene'; import Scene from './Scene';
import { CardObjectMesh } from './CardMesh'; import { CardObjectMesh } from './CardMesh';
import type { ViewerProps } from '../viewers';
/** /**
* A playing card: a thin rounded rect with the face texture on the front and * A playing card: a thin rounded rect with the face texture on the front and
@@ -24,9 +25,9 @@ import { CardObjectMesh } from './CardMesh';
* It is flipped left/right so it isn't mirrored when viewed from the back of * It is flipped left/right so it isn't mirrored when viewed from the back of
* the card. * the card.
*/ */
export default function CardViewer({ object }: { object: TTSObject }) { export default function CardViewer({ object, fill }: ViewerProps) {
return ( return (
<Scene> <Scene fill={fill}>
<CardObjectMesh object={object} /> <CardObjectMesh object={object} />
</Scene> </Scene>
); );
@@ -1,6 +1,7 @@
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import Scene from './Scene'; import Scene from './Scene';
import { CustomModelMesh } from './CustomModelMesh'; import { CustomModelMesh } from './CustomModelMesh';
import type { ViewerProps } from '../viewers';
/** /**
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ, * A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
@@ -8,9 +9,9 @@ import { CustomModelMesh } from './CustomModelMesh';
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the * (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
* model's materials when present. * model's materials when present.
*/ */
export default function CustomModelViewer({ object }: { object: TTSObject }) { export default function CustomModelViewer({ object, fill }: ViewerProps) {
return ( return (
<Scene> <Scene fill={fill}>
<CustomModelMesh object={object} /> <CustomModelMesh object={object} />
</Scene> </Scene>
); );
@@ -0,0 +1,259 @@
import { useMemo, useRef, useState } from 'react';
import type { RefObject } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import { useBounds } from '@react-three/drei';
import Scene from './Scene';
import { CardObjectMesh } from './CardMesh';
import type { ViewerProps } from '../viewers';
/** How many cards to show to each side of the active card. */
const HALF_WINDOW = 3;
/** Angular spacing between adjacent cards in the arc, in radians. */
const ARC_STEP = 0.32;
/** Minimum radius of the arc, in world units. */
const ARC_RADIUS = 3.2;
/** Extra clearance between the active card's edge and its neighbors, in world units. */
const ARC_PADDING = 0.2;
/**
* A deck carousel: the deck's contained cards are fanned in a 3D arc with the
* active card front and center. Prev/next controls step through the deck, each
* card animating to its new slot. Side cards are turned 90° in y (album flow)
* so only the active card's face is framed; all neighbors edge-on around it.
*
* The camera is fitted to just the active card (not the whole carousel): the
* shared scene's auto-fit is disabled and `useBounds` refits whenever the
* selection changes.
*
* Only a window of cards around the active one is rendered (the rest stay
* hidden), so large decks stay lean. Falls back to a single card (the deck
* object itself) when there are no contained cards.
*/
export default function DeckViewer({ object, fill }: ViewerProps) {
const cards = (object.ContainedObjects ?? []).filter(
(o) => o.CardID != null || o.CustomImage != null,
);
const count = cards.length;
const [active, setActive] = useState(0);
const hasCards = count > 0;
const centerRef = useRef<THREE.Group>(null);
// The active card's world-space width, measured once it's laid out. Used to
// widen the arc so neighbors clear the card's edges (a fixed radius only fits
// square cards; wider cards clip their neighbors).
const [cardWidth, setCardWidth] = useState<number | null>(null);
const step = (dir: number) => setActive((a) => (a + dir + count) % count);
const radius = arcRadius(cardWidth);
const visible = useMemo(
() =>
cards
.map((card, i) => ({ card, i, k: i - active }))
.filter((v) => Math.abs(v.k) <= HALF_WINDOW),
[cards, active],
);
// The active card always settles to the arc center, so the camera only needs
// to frame it once on mount.
const didFit = useRef(false);
return (
<Scene
fit={false}
autoRotate={false}
fill={fill}
overlay={
hasCards ? (
<CarouselControls active={active} count={count} onStep={step} />
) : undefined
}
>
{hasCards ? (
<>
{visible.map(({ card, i, k }) => (
// Key by the card's index (stable across renders) so the element
// persists and tweens as its slot changes; the index-path key is
// unique even though cards in a deck share the same GUID.
<CarouselCard
key={i}
card={card}
k={k}
radius={radius}
groupRef={k === 0 ? centerRef : undefined}
/>
))}
<FitActive
targetRef={centerRef}
didFit={didFit}
onMeasure={setCardWidth}
/>
</>
) : (
<CardObjectMesh object={object} />
)}
</Scene>
);
}
/**
* Fits the camera once, on the first frame, to frame the active card from the
* front. Runs in a frame callback (not an effect) so the active card has been
* moved to its arc slot by its own `useFrame` first — otherwise the group is
* still at the origin and the camera would frame the carousel center.
*
* The card's front face points toward +Z, so the camera is placed directly in
* front of it and looks straight at it, rather than keeping its initial side
* angle (which drei's `fit()` would do). The active card always settles in the
* same slot, so no refit is needed while navigating — that would restart the
* camera tween on every step and feel laggy.
*/
function FitActive({
targetRef,
didFit,
onMeasure,
}: {
targetRef: RefObject<THREE.Group | null>;
didFit: RefObject<boolean>;
onMeasure: (width: number) => void;
}) {
const bounds = useBounds();
// Whether the active card's width has been measured once. The fit waits one
// frame after measuring so the arc radius (and thus the active card's slot)
// has settled before framing it.
const measured = useRef(false);
useFrame(() => {
const node = targetRef.current;
if (!node) return;
// Stop refreshing once fit: drei's `bounds.refresh()` resets the camera
// goal, so calling it every frame would wipe the fit we just set before
// the camera tween ever runs.
if (didFit.current) return;
bounds.refresh(node);
const { size, center, distance } = bounds.getSize();
// Report the active card's width so the arc radius can widen for wide
// cards (see `arcRadius`).
onMeasure(size.x);
if (!measured.current) {
measured.current = true;
return;
}
didFit.current = true;
// The card's front face points toward +Z, so put the camera in front of it
// and look straight at it.
bounds
.moveTo([center.x, center.y, center.z + distance])
.lookAt({ target: center });
});
return null;
}
/** A single card that tweens into its arc slot each frame. */
function CarouselCard({
card,
k,
radius,
groupRef,
}: {
card: TTSObject;
k: number;
radius: number;
groupRef?: RefObject<THREE.Group | null>;
}) {
const localRef = useRef<THREE.Group>(null);
const group = groupRef ?? localRef;
// Start at the target so the first render doesn't tween into place.
const state = useRef(slotTransform(k, radius));
const target = useMemo(() => slotTransform(k, radius), [k, radius]);
useFrame((_, dt) => {
const g = group.current;
if (!g) return;
// Smooth per-frame damping independent of frame rate.
const f = 1 - Math.pow(0.0001, dt);
const t = target;
const s = state.current;
s.x += (t.x - s.x) * f;
s.z += (t.z - s.z) * f;
s.rot += (t.rot - s.rot) * f;
s.scale += (t.scale - s.scale) * f;
g.position.set(s.x, 0, s.z);
g.rotation.y = s.rot;
g.scale.setScalar(s.scale);
});
return (
<group ref={group}>
<CardObjectMesh object={card} />
</group>
);
}
interface Slots {
x: number;
z: number;
rot: number;
scale: number;
}
/** World transform for a card at arc offset `k` (0 = front and center). */
function slotTransform(k: number, radius: number): Slots {
const ang = k * ARC_STEP;
// Side cards turn edge-on (album flow); the active card stays forward.
const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2);
return {
x: Math.sin(ang) * radius,
z: Math.cos(ang) * radius,
rot: turn,
scale: 1.15 - 0.15 * Math.abs(k),
};
}
/**
* Arc radius that keeps the nearest neighbors clear of the active card's
* edges. A neighbor at `k = 1` sits at `x = sin(ARC_STEP) * radius`, so the
* radius must exceed `cardWidth / 2 / sin(ARC_STEP)` for the neighbor to clear
* the card's half-width (plus padding). Falls back to the minimum when the
* card width isn't known yet.
*/
function arcRadius(cardWidth: number | null): number {
if (cardWidth == null) return ARC_RADIUS;
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
}
/** Prev/next controls and a counter, rendered as the scene overlay. */
function CarouselControls({
active,
count,
onStep,
}: {
active: number;
count: number;
onStep: (dir: number) => void;
}) {
const prev = () => onStep(-1);
const next = () => onStep(1);
return (
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
<button
onClick={prev}
aria-label="Previous card"
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
<span className="rounded-md bg-zinc-900/80 px-3 py-1 font-mono text-xs text-zinc-300 backdrop-blur">
{active + 1} / {count}
</span>
<button
onClick={next}
aria-label="Next card"
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
</div>
);
}
@@ -0,0 +1,43 @@
import type { TTSObject } from '@tts/shared';
import { pdfUrl } from '@tts/http';
/**
* A PDF document (`Custom_PDF`). Renders the document inline in an iframe and
* offers a standalone link to open it in a new tab. The URL is routed through
* the proxy `/pdf` endpoint, which forces `Content-Disposition: inline` so the
* browser displays the PDF instead of downloading it.
*/
export default function PdfViewer({ object }: { object: TTSObject }) {
const url = object.CustomPDF?.PDFUrl;
if (!url) {
return (
<p className="text-sm text-zinc-500">
This object has no PDF URL.
</p>
);
}
const proxied = pdfUrl(url);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-mono text-xs text-zinc-500">{url}</p>
<a
href={proxied}
target="_blank"
rel="noreferrer"
className="shrink-0 rounded-lg bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
>
Open PDF in new tab
</a>
</div>
<iframe
src={proxied}
title="PDF preview"
className="aspect-[3/4] w-full rounded-lg border border-zinc-800 bg-zinc-950"
/>
</div>
);
}
+19 -3
View File
@@ -16,15 +16,25 @@ import { EffectComposer, Vignette } from '@react-three/postprocessing';
* *
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for * `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
* example, pans instead of rotating). `fullscreen` adds a toggle button that * example, pans instead of rotating). `fullscreen` adds a toggle button that
* expands the scene to the full screen. `maxPolarAngle` (radians) clamps how * `maxPolarAngle` (radians) clamps how
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from * far the camera can tilt below the horizon, e.g. to stop a tabletop view from
* peeking under face-down cards. * peeking under face-down cards.
*
* `fit` (default true) bounds, fits, and clips the camera to the scene's
* content on mount and resize. A viewer that needs to frame a specific part of
* its content (e.g. the active card in a deck carousel) can set it to false and
* call `useBounds()` itself to refit.
*
* By default the scene renders in an `aspect-[3/4]` frame; set `fill` to expand
* to the full height of its container (used by the inspector view).
*/ */
export default function Scene({ export default function Scene({
children, children,
autoRotate = true, autoRotate = true,
enablePan = false, enablePan = false,
fullscreen = false, fullscreen = false,
fit = true,
fill = false,
overlay, overlay,
shadowScale = 22, shadowScale = 22,
maxPolarAngle = Math.PI, maxPolarAngle = Math.PI,
@@ -33,6 +43,10 @@ export default function Scene({
autoRotate?: boolean; autoRotate?: boolean;
enablePan?: boolean; enablePan?: boolean;
fullscreen?: boolean; fullscreen?: boolean;
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
fit?: boolean;
/** Expand to fill the container height instead of the default aspect-[3/4] frame. */
fill?: boolean;
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */ /** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
overlay?: ReactNode; overlay?: ReactNode;
/** Contact shadow plane size in world units; defaults to a generous 22. */ /** Contact shadow plane size in world units; defaults to a generous 22. */
@@ -57,7 +71,9 @@ export default function Scene({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400" className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
fill ? 'h-full min-h-0' : 'aspect-[3/4]'
}`}
> >
<LoadingOverlay /> <LoadingOverlay />
{fullscreen && ( {fullscreen && (
@@ -82,7 +98,7 @@ export default function Scene({
<pointLight position={[0, 3, 0]} intensity={0.3} /> <pointLight position={[0, 3, 0]} intensity={0.3} />
<Suspense fallback={null}> <Suspense fallback={null}>
<Bounds fit observe clip>{children}</Bounds> <Bounds fit={fit} observe={fit} clip={fit}>{children}</Bounds>
</Suspense> </Suspense>
<ContactShadows <ContactShadows
@@ -1,6 +1,7 @@
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import Scene from './Scene'; import Scene from './Scene';
import { TileObjectMesh } from './TileMesh'; import { TileObjectMesh } from './TileMesh';
import type { ViewerProps } from '../viewers';
/** /**
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL` * A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
@@ -10,9 +11,9 @@ import { TileObjectMesh } from './TileMesh';
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the * When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
* source image instead of being forced square. * source image instead of being forced square.
*/ */
export default function TileViewer({ object }: { object: TTSObject }) { export default function TileViewer({ object, fill }: ViewerProps) {
return ( return (
<Scene> <Scene fill={fill}>
<TileObjectMesh object={object} /> <TileObjectMesh object={object} />
</Scene> </Scene>
); );
@@ -1,6 +1,7 @@
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import Scene from './Scene'; import Scene from './Scene';
import { TokenObjectMesh } from './TokenMesh'; import { TokenObjectMesh } from './TokenMesh';
import type { ViewerProps } from '../viewers';
/** /**
* A token: a short extruded shape with the texture on its top face. Uses * A token: a short extruded shape with the texture on its top face. Uses
@@ -8,9 +9,9 @@ import { TokenObjectMesh } from './TokenMesh';
* color when absent. The footprint is traced from the image's alpha channel via * color when absent. The footprint is traced from the image's alpha channel via
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette. * the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
*/ */
export default function TokenViewer({ object }: { object: TTSObject }) { export default function TokenViewer({ object, fill }: ViewerProps) {
return ( return (
<Scene> <Scene fill={fill}>
<TokenObjectMesh object={object} /> <TokenObjectMesh object={object} />
</Scene> </Scene>
); );
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { applyMapTransform } from './cardMaterial';
describe('applyMapTransform', () => {
it('injects the repeat/offset uniforms and UV transform into the shader', () => {
const mat = new THREE.MeshStandardMaterial();
applyMapTransform(mat, new THREE.Vector2(0.5, 0.25), new THREE.Vector2(0.1, 0.2));
expect(mat.onBeforeCompile).toBeTypeOf('function');
const shader = {
uniforms: {} as Record<string, { value: unknown }>,
vertexShader: '#include <uv_vertex>\nvoid main() {}',
};
mat.onBeforeCompile!(shader as never, {} as never);
// Uniforms are copied, so the caller's vectors stay reusable.
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(0.5, 0.25));
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0.1, 0.2));
// The uniforms are declared in the GLSL (three.js does not auto-declare
// uniforms added via `onBeforeCompile`), and the override is injected right
// after the chunk include, which stays in place (it declares `vMapUv`/`uv`).
expect(shader.vertexShader).toContain('uniform vec2 uMapRepeat;');
expect(shader.vertexShader).toContain('uniform vec2 uMapOffset;');
expect(shader.vertexShader).toContain('#include <uv_vertex>\n\tvMapUv = uv * uMapRepeat + uMapOffset;');
});
it('copies the vectors so later mutation of the inputs has no effect', () => {
const mat = new THREE.MeshStandardMaterial();
const repeat = new THREE.Vector2(1, 1);
const offset = new THREE.Vector2(0, 0);
applyMapTransform(mat, repeat, offset);
repeat.set(9, 9);
offset.set(9, 9);
const shader = { uniforms: {} as Record<string, { value: unknown }>, vertexShader: '' };
mat.onBeforeCompile!(shader as never, {} as never);
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(1, 1));
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0, 0));
});
});
@@ -0,0 +1,51 @@
import * as THREE from 'three';
/**
* Per-card UV transform injected into a `MeshStandardMaterial` shader.
*
* Cards share one texture (the deck's sprite sheet, cached by drei) and one
* geometry, but each card samples a different sprite cell. Rather than cloning
* the texture per card (which re-uploads the sheet on every GPU bind), the
* repeat/offset is pushed into the material as a uniform. The shader source is
* identical across cards, so three.js still compiles a single shared program.
*
* We inject our own uniform instead of setting `texture.repeat`/`offset`
* because three r185 derives the map UVs from a `mapTransform` matrix that is
* refreshed from `map.matrix` every frame, overwriting any per-material
* transform we set on the shared texture.
*/
// Uniforms added via `onBeforeCompile` are not auto-declared by three.js, so
// they must be declared in the GLSL explicitly (the built-in `mapTransform` is
// declared in `uv_pars_vertex.glsl.js`).
const UNIFORM_DECLS = /* glsl */ `
uniform vec2 uMapRepeat;
uniform vec2 uMapOffset;
`;
const VERTEX_INJECT = /* glsl */ `
#include <uv_vertex>
vMapUv = uv * uMapRepeat + uMapOffset;
`;
/**
* Apply a repeat/offset to a card material's map sampling. Call once per
* material (the transform is baked into the shader). `repeat`/`offset` are
* copied, so the caller may reuse the vectors.
*/
export function applyMapTransform(
material: THREE.MeshStandardMaterial,
repeat: THREE.Vector2,
offset: THREE.Vector2,
): void {
// Clone eagerly so later mutation of the caller's vectors can't leak into
// the uniform once the material is compiled.
const r = repeat.clone();
const o = offset.clone();
material.onBeforeCompile = (shader) => {
shader.uniforms.uMapRepeat = { value: r };
shader.uniforms.uMapOffset = { value: o };
shader.vertexShader =
UNIFORM_DECLS +
shader.vertexShader.replace('#include <uv_vertex>', VERTEX_INJECT);
};
}
+7 -4
View File
@@ -8,16 +8,19 @@ import { registerViewer } from '../viewers';
const TileViewer = lazy(() => import('./TileViewer')); const TileViewer = lazy(() => import('./TileViewer'));
const TokenViewer = lazy(() => import('./TokenViewer')); const TokenViewer = lazy(() => import('./TokenViewer'));
const CardViewer = lazy(() => import('./CardViewer')); const CardViewer = lazy(() => import('./CardViewer'));
const DeckViewer = lazy(() => import('./DeckViewer'));
const CustomModelViewer = lazy(() => import('./CustomModelViewer')); const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
const PdfViewer = lazy(() => import('./PdfViewer'));
registerViewer('Tile', TileViewer); registerViewer('Tile', TileViewer);
registerViewer('Custom_Tile', TileViewer); registerViewer('Custom_Tile', TileViewer);
registerViewer('Custom_Token', TokenViewer); registerViewer('Custom_Token', TokenViewer);
registerViewer('Card', CardViewer); registerViewer('Card', CardViewer);
registerViewer('CardCustom', CardViewer); registerViewer('CardCustom', CardViewer);
registerViewer('Deck', CardViewer); registerViewer('Deck', DeckViewer);
registerViewer('DeckCustom', CardViewer); registerViewer('DeckCustom', DeckViewer);
registerViewer('Custom_Deck', CardViewer); registerViewer('Custom_Deck', DeckViewer);
registerViewer('Custom_Model', CustomModelViewer); registerViewer('Custom_Model', CustomModelViewer);
registerViewer('Custom_Model_Bag', CustomModelViewer); registerViewer('Custom_Model_Bag', CustomModelViewer);
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer); registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
registerViewer('Custom_PDF', PdfViewer);
+32 -74
View File
@@ -4,7 +4,6 @@ import { Link, useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract'; import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore'; import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore'; import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
import ObjectTree from '../components/ObjectTree'; import ObjectTree from '../components/ObjectTree';
import { ErrorBoundary } from '@tts/tabletop'; import { ErrorBoundary } from '@tts/tabletop';
import { resolveViewer } from '../components/viewers'; import { resolveViewer } from '../components/viewers';
@@ -25,8 +24,8 @@ export default function ModPage() {
if (id) load(id, item?.fileUrl); if (id) load(id, item?.fileUrl);
}, [id, item?.fileUrl, load]); }, [id, item?.fileUrl, load]);
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]); const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]); const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
const selected = useMemo( const selected = useMemo(
() => (mod ? findInTree(tree, selectedPath) : undefined), () => (mod ? findInTree(tree, selectedPath) : undefined),
@@ -40,78 +39,37 @@ export default function ModPage() {
const Viewer = selected ? resolveViewer(selected.object) : null; const Viewer = selected ? resolveViewer(selected.object) : null;
return ( return (
<div className="space-y-6"> <div className="flex h-full min-h-0">
<div> {/* Independently scrolling tree; the filter bar stays pinned above it. */}
<h1 className="text-2xl font-semibold">Mod {id}</h1> <aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
<p className="mt-1 text-sm text-zinc-400"> <ObjectTree
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '} nodes={tree}
asset refs selectedPath={selectedPath}
</p> onSelect={setSelectedPath}
<div className="mt-3 flex gap-2"> />
<a </aside>
href={modFileUrl(id!, item?.fileUrl)}
className="inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
>
Download save file
</a>
<Link
to={`/mod/${id}/setup`}
className="inline-block rounded-lg border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-800"
>
Full setup
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]"> <section className="h-full min-h-0 flex-1 p-4">
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2"> {selected && Viewer ? (
<ObjectTree /* Key by selection path so the Canvas remounts and the camera
nodes={tree} refits to the newly selected object. */
selectedPath={selectedPath} <ErrorBoundary>
onSelect={setSelectedPath} <Suspense
/> fallback={
</aside> <div className="flex h-full items-center justify-center text-sm text-zinc-500">
Loading viewer
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4"> </div>
{selected ? ( }
<> >
<header className="mb-4"> <Viewer key={selectedPath} object={selected.object} fill />
<span </Suspense>
title={selected.object.Name} </ErrorBoundary>
className="inline-flex items-center gap-1 text-zinc-400" ) : (
> <div className="flex h-full items-center justify-center text-sm text-zinc-500">
{iconsForObject(selected.object.Name).map((icon) => ( Select an object from the tree to inspect it.
<Icon key={icon} icon={icon} className="h-5 w-5" /> </div>
))} )}
</span> </section>
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
<p className="font-mono text-xs text-zinc-500">
{selected.object.GUID}
</p>
</header>
{Viewer && (
<ErrorBoundary>
<Suspense
fallback={
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
Loading viewer
</div>
}
>
{/* Key by selection path so the Canvas remounts and the
camera refits to the newly selected object. */}
<Viewer key={selectedPath} object={selected.object} />
</Suspense>
</ErrorBoundary>
)}
</>
) : (
<p className="text-sm text-zinc-500">
Select an object from the tree to inspect it.
</p>
)}
</section>
</div>
</div> </div>
); );
} }
+5 -5
View File
@@ -1,11 +1,11 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { TTSMod } from '@tts/shared'; import type { ModDetails } from '@tts/shared';
import { fetchMod } from '../api'; import { fetchMod } from '../api';
interface ModState { interface ModState {
id: string | null; id: string | null;
fileUrl?: string; fileUrl?: string;
mod: TTSMod | null; mod: ModDetails | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
load: (id: string, fileUrl?: string) => Promise<void>; load: (id: string, fileUrl?: string) => Promise<void>;
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
load: async (id, fileUrl) => { load: async (id, fileUrl) => {
set({ loading: true, error: null, id, fileUrl }); set({ loading: true, error: null, id, fileUrl });
try { try {
const mod = await fetchMod(id, fileUrl); const details = await fetchMod(id, fileUrl);
set({ mod, loading: false }); set({ mod: details, loading: false });
} catch (err) { } catch (err) {
set({ loading: false, error: String(err) }); set({ loading: false, error: String(err) });
} }
}, },
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }), clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
})); }));
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig({
'/items': 'http://localhost:3000', '/items': 'http://localhost:3000',
'/health': 'http://localhost:3000', '/health': 'http://localhost:3000',
'/asset': 'http://localhost:3000', '/asset': 'http://localhost:3000',
'/pdf': 'http://localhost:3000',
'/trace': 'http://localhost:3000', '/trace': 'http://localhost:3000',
}, },
}, },
+1
View File
@@ -32,6 +32,7 @@ shared types/validation package.
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node | | `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic | | `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic | | `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
| `packages/engine` | Message bus, queue/tick, triggers, orchestrators | Isomorphic |
| `packages/shared` | Shared types + zod schemas | Isomorphic | | `packages/shared` | Shared types + zod schemas | Isomorphic |
## Dependency graph ## Dependency graph
+29 -20
View File
@@ -6,18 +6,22 @@ interaction — focus the camera, wait for a tap, move a part, show a caption
that runs against the tabletop state store and render layer. that runs against the tabletop state store and render layer.
This doc covers **command execution**: the async lifecycle, run contexts, and This doc covers **command execution**: the async lifecycle, run contexts, and
tap interaction. How commands are *declared* (the `script` role, trigger tap interaction. The message layer above this — how commands are *declared*
points on parts) is a separate concern, deferred to `bgm-format.md`. and *fired* (triggers, orchestrators, the message queue) — is specified in
[`bgm-engine.md`](./bgm-engine.md). Commands are async functions registered
with the engine's handler registry; `@tts/tabletop` provides the concrete
commands that mutate the tabletop store and render layer.
## 1. async commands ## 1. async commands
A command is an async function that returns a result. Every command ends in A command is an async function that returns a result. Every command ends in
one of three states: one of three states, emitted as a message discriminated on the type suffix
(see `bgm-engine.md` §3):
- `ok` — completed normally. - `:done` — completed normally.
- `cancel` — interrupted (a newer command superseded it, the user skipped, the - `:cancel` — interrupted (a newer command superseded it, the user skipped,
surface was disabled). **Not a failure.** the surface was disabled). **Not a failure.**
- `error` — genuinely failed (asset missing, bad path, a thrown exception). - `:error` — genuinely failed (asset missing, bad path, a thrown exception).
`cancel` is distinct from `error`: a superseded or skipped command stops `cancel` is distinct from `error`: a superseded or skipped command stops
cleanly, while a broken command surfaces loudly. The runtime treats them cleanly, while a broken command surfaces loudly. The runtime treats them
@@ -25,10 +29,10 @@ differently — a script that is superseded unwinds without alarming the player,
but an `error` is reported. but an `error` is reported.
```ts ```ts
type CommandResult = type CommandResult<Name extends string, R = void> =
| { status: 'ok' } | { type: `${Name}:done`; data: R }
| { status: 'cancel' } | { type: `${Name}:cancel` }
| { status: 'error'; error: Error }; | { type: `${Name}:error`; error: Error };
``` ```
## 2. run contexts ## 2. run contexts
@@ -69,16 +73,18 @@ lifecycle.
**Supersede groups** cancel a running command when another in the same group **Supersede groups** cancel a running command when another in the same group
starts. A `focus` command belongs to a `camera` group, so a second `focus` starts. A `focus` command belongs to a `camera` group, so a second `focus`
cancels the first. cancels the first. A superseded command's `signal` is aborted, and it emits
`:cancel`.
A command is an async function taking the `RunContext` (with its `args`):
```ts ```ts
interface Command { type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
id: string;
supersede?: string; // group; starting one cancels others in it
execute(ctx: CommandContext): Promise<CommandResult>;
}
``` ```
The engine wraps it: it builds the context from the message, runs the function,
and emits `:done` on resolve, `:cancel` on abort, `:error` on throw.
## 4. tap interaction ## 4. tap interaction
Only tap interaction is supported. A tap on a part is detected and reported to Only tap interaction is supported. A tap on a part is detected and reported to
@@ -115,12 +121,15 @@ Rules:
Commands subscribe to the tap stream via the context and unsubscribe on Commands subscribe to the tap stream via the context and unsubscribe on
cancel, so a cancelled `wait: tap` never leaks a handler. cancel, so a cancelled `wait: tap` never leaks a handler.
## 5. command context ## 5. run context
The context a command receives is the handle to everything it can affect: The context a command receives is the handle to everything it can affect. The
engine defines the base `RunContext` (see `bgm-engine.md` §5): `signal`
(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop
extends it with the handles commands need to mutate the board:
```ts ```ts
interface CommandContext { interface TabletopRunContext extends RunContext {
pkg: Package; pkg: Package;
store: TabletopStore; // movePart, setPart, enableSurface, ... store: TabletopStore; // movePart, setPart, enableSurface, ...
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
+260
View File
@@ -0,0 +1,260 @@
# bgm-engine
The message layer that drives [bgm](./bgm-format.md) board games, built into
[`@tts/engine`](./architecture.md). It unifies the two halves of scripted
interaction — *declaring* what should happen and *executing* it — into a single
reactive loop: **messages** flow through a **queue**, and **handlers** react to
them.
This doc covers the message model (what flows), the queue and its tick (how it
flows), and the handlers (who reacts). Command *execution* — the async
lifecycle, run contexts, and tap interaction — is specified in
[`bgm-commands.md`](./bgm-commands.md); this doc is the layer above it.
## package split
The engine is a **pure** package: the message bus, queue, tick, trigger
registry, and the handler runner. It has no r3f, no React, and no store, so it
is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep
style). It defines the contract — `Message`, the handler registry, `Trigger`,
`Orchestrator`, and `RunContext`.
[`@tts/tabletop`](./bgm-tabletop.md) is one consumer of that contract: it
registers the built-in commands (`move`, `focus`, `caption`, `enableSurface`,
...) that mutate the tabletop store and drive the render layer. The engine
never imports tabletop; tabletop depends on the engine for the message types
and the handler registry. A headless sim or bot harness can consume the engine
without the render layer.
## 1. messages
A **message** is the unit of communication. It is both an *event* (something
happened) and an *intent* (something should happen) — the two are the same
thing. A message is dispatched to the handlers registered for its `type`; a
handler may emit new messages in response.
Messages are a **discriminated union** on `type`. The engine defines the
generic shapes; the host's concrete union extends them with its own command
types.
```ts
interface TapMessage {
type: 'tap';
data: TapEvent; // part, position, trigger
}
interface CommandMessage<Name extends string, Args> {
type: Name;
data: Args;
}
```
A message is identified by `type`, matching the format's `type#id`
convention. A `move` message both *runs* the move command and is *observable*
as an event; the command's completion is itself a message, which is what
triggers match and orchestrators await.
The loop is just: **message → handler → message**. Handlers consume messages
and emit new ones; the queue serializes them.
## 2. the queue and ticking
Messages are not processed inline. They are **enqueued** and handled on the
next **tick**. This kills reentrancy (a handler cannot cause unbounded
recursion), gives a natural debounce, and makes the whole system a
deterministic frame.
### tick contract
The engine is pure — it has no render loop and must stay node-testable. It
exposes `tick()`, and the host calls it:
- In `@tts/tabletop`, a `useFrame` drives `tick()`.
- In tests, `tick()` is called manually.
The engine never assumes a render loop.
### drain semantics
- **Snapshot-and-drain.** At `tick()`, snapshot the queue and process it.
Messages emitted *during* the drain go to the *next* tick. This guarantees
no reentrancy within a drain and makes ordering deterministic.
- **FIFO within a tick.** Simple and predictable.
- **One tick drains the whole snapshot** (not one message per tick), so a
burst of messages all resolve in one frame.
### awaiting
A handler suspends on `await ctx.wait(pred)` and resumes when a matching
message is processed during a drain. Its own emissions go to the next tick, so
it cannot re-enter itself.
## 3. message types
### interaction messages
Interaction is the player's input, reported to the engine as messages. Only
tap interaction is supported (see `bgm-commands.md` §4).
```ts
interface TapMessage {
type: 'tap';
data: TapEvent;
}
```
A tap on a part is reported with the nearest trigger point (or `null` on a
miss). The handler decides how to react — resolve, reject with a "wrong spot"
shake, or ignore. The runtime stays dumb; the handler owns the UX.
### command messages
A command message names a command to run. Its handler is the command
implementation; its completion is emitted as a result message. A command's
result is a **discriminated union on the type suffix**, carrying the terminal
state:
```ts
type CommandResult<Name extends string, R = void> =
| { type: `${Name}:done`; data: R }
| { type: `${Name}:cancel` }
| { type: `${Name}:error`; error: Error };
// e.g. move:done { data: MoveResult } | move:cancel | move:error
```
The command-id-as-key convention means a message both *is* the intent and
*observes* the result. `move:done`, `focus:done`, etc. are the messages that
triggers match and orchestrators await. A cancelled command emits `:cancel`, an
errored one `:error` — a trigger matching `move:done` does not fire on a
cancel.
## 4. handlers
There are three kinds of handler. All three consume messages and emit
messages; they differ in how they're declared and how they run.
| Handler | Declared | Runs | Purpose |
| --- | --- | --- | --- |
| **Trigger** | data (yaml) | synchronously on match | declarative reactive glue |
| **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow |
| **Command** | code (built-in) | async, on its message | atomic execution |
### triggers — declarative reactive glue
A trigger matches a message by `type` and named params, and emits messages in
response. It is declared as data, keyed by `role+type+id` like other defs, and
collision-checked the same way.
```yaml
role: trigger
type: tap
id: draw
match:
part: carcassonne:tile#a
trigger: draw
emit:
- move: { part: carcassonne:tile#a, to: /grid/5/5 }
- focus: { path: /grid/5/5 }
```
- `type` selects the message kind; `match` binds named params from the
payload (like a route's candidates).
- `emit` uses the command-id-as-key convention.
- Multiple triggers can match the same message — both fire, which is usually
what you want.
- A trigger is a **pre-registered handler**: it's a message consumer that
emits commands. An orchestrator can do the same thing imperatively with
`ctx.on(...)`.
### orchestrators — imperative async flow
An orchestrator is the code counterpart to a trigger: an async function that
emits messages and awaits matching ones. It is a proper TS module, declared
per folder as `main.ts` — unique per folder like `package.yaml`.
```ts
// main.ts
export default async function main(ctx: RunContext): Promise<void> {
await ctx.focus({ path: '/deck' });
await ctx.caption({ text: 'Draw a tile' });
const tap = await ctx.wait((m) => m.type === 'tap');
await ctx.move({ part: tap.data.part, to: '/grid/5/5' });
}
```
- **`main.ts` is executable code, loaded by the host, not the engine.** The
engine defines the contract (the orchestrator type and runner); the host
dynamically imports `main.ts` and hands the exported orchestrator to the
engine. The engine never imports user code.
- **A default export async function.** `main.ts` exports a single async
function as its default export, taking the `RunContext`. It is the folder's
orchestrator.
- **Trigger control lives here.** The orchestrator toggles triggers at runtime
by their `type#id`:
```ts
ctx.enableTrigger('tap', 'draw');
ctx.disableTrigger('tap', 'draw');
```
Declaration is data; activation is code. The orchestrator owns game-flow
logic ("no more placements this turn" → disable the trigger), while the
trigger stays a dumb declarative mapping.
### commands — atomic execution
A command is an async function, the same shape as an orchestrator. It takes a
`RunContext` (with its `args`), returns its result, and throws on error. The
engine wraps it: it builds the context from the message, runs the function, and
emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on
throw.
```ts
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
```
Commands are the **single mutation path** — the only way state changes.
Triggers and orchestrators never mutate state directly; they emit command
messages, and the command handlers execute them.
## 5. run context
Every handler runs against a `RunContext`, the handle to everything it can
affect and the unit of cancellation.
```ts
interface RunContext {
signal: AbortSignal; // cancellation: superseded, skipped, surface disabled
emit(msg: Message): void;
wait(pred: (m: Message) => boolean): Promise<Message>; // rejects on abort
enableTrigger(type: string, id?: string): void;
disableTrigger(type: string, id?: string): void;
}
```
- **Cancellation** is an `AbortSignal`. A superseded command or a disabled
surface aborts the signal; a `wait` rejects on abort, and the command's
`:cancel` result is emitted.
- **Errors** are thrown. A command that throws emits `:error`; an orchestrator
that throws surfaces loudly.
- Commands and orchestrators are the same shape: an async function taking the
context. An orchestrator is a command that returns `void` and is never
awaited by a parent.
## 6. solo-only
This design is **solo-only** — no multiplayer. Other players either don't
exist or are automated with an automata. An automata is just another message
consumer that emits commands: a stateful trigger or orchestrator. The engine
doesn't care whether a `tap` message came from a human or a bot decision —
same queue, same handlers. Solo-only simplifies the design: no network, no
sync, no authoritative-server concerns. "Other players" are just more message
producers.
## Open decisions
- **`main.ts` loading.** The host dynamically imports `main.ts`; the exact
loading boundary (Vite dynamic import, error handling, HMR) is deferred to
implementation. The engine defines the orchestrator type; the host loads the
module and hands the exported orchestrator to the engine.
+3 -1
View File
@@ -53,7 +53,9 @@ the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` /
Scripted interaction — focus, tap-to-advance, move, caption — is built on an Scripted interaction — focus, tap-to-advance, move, caption — is built on an
async command layer. See [`bgm-commands.md`](./bgm-commands.md) for command async command layer. See [`bgm-commands.md`](./bgm-commands.md) for command
execution (lifecycle, run contexts, tap interaction). execution (lifecycle, run contexts, tap interaction), and
[`bgm-engine.md`](./bgm-engine.md) for the message layer above it (the queue,
triggers, and orchestrators that declare and fire commands).
## 6. usage ## 6. usage
+25 -1
View File
@@ -299,4 +299,28 @@ flips.
**Alternatives considered:** Reporting only a hit and silently dropping **Alternatives considered:** Reporting only a hit and silently dropping
misses. Rejected — a command that needs to react to a wrong tap has no way to misses. Rejected — a command that needs to react to a wrong tap has no way to
do so. World-space trigger points. Rejected — they break when the part moves. do so. World-space trigger points. Rejected — they break when the part moves.
## D21 — Card sprite UVs live in the material shader, not the texture
**Decision:** A card's sprite cell is selected by a repeat/offset injected into
the material's shader (`cardMaterial.ts` extends `MeshStandardMaterial` via
`onBeforeCompile`) rather than by cloning the texture and setting its
`repeat`/`offset`.
**Context:** Cards in a deck share one sprite sheet (drei caches the texture by
URL), but each card samples a different cell. The previous approach cloned the
texture per card to set its UVs; each clone gets its own WebGL texture binding,
so navigating a deck re-uploaded the whole sheet on every step. Moving the
transform into a per-material uniform lets cards share the texture (one GPU
upload), the shader (identical injected source → one program), and the geometry,
with only the material uniforms differing.
We inject our own uniform rather than setting `texture.repeat`/`offset` because
three r185 derives map UVs from a `mapTransform` matrix refreshed from
`map.matrix` every frame, which would overwrite a per-material transform set on
the shared texture.
**Alternatives considered:** Cloning the texture per card (previous approach).
Rejected — re-uploads the sheet per card. A module-level cache of per-card
clones. Rejected — still one upload per unique card instead of one per sheet.
+4 -3
View File
@@ -81,9 +81,10 @@ view and the full-setup view.
`textureUrl + color + roughness`. drei already caches textures by URL `textureUrl + color + roughness`. drei already caches textures by URL
globally, so sharing the material on top avoids per-object material globally, so sharing the material on top avoids per-object material
allocation for tiles/tokens with the same image. allocation for tiles/tokens with the same image.
- **Cards are the exception:** each card clones its texture for sprite UVs, so - **Cards:** the face/back textures are shared (drei caches them by URL) and
its face material cannot be shared — but its geometry still can (same card the sprite cell is selected via a per-material UV transform injected into the
size). shader (`cardMaterial.ts`), so cards share texture, shader, and geometry —
only the material uniforms differ. Materials are cached per card id + tint.
- Dispose shared resources on page unmount, or accept a module-level cache for - Dispose shared resources on page unmount, or accept a module-level cache for
the session (see Open decisions). the session (see Open decisions).
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@tts/engine",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo \"no lint configured\""
},
"devDependencies": {
"typescript": "^5.7.2"
}
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { Engine, type Message } from './engine.js';
describe('Engine', () => {
it('dispatches a message to handlers registered for its type', () => {
const engine = new Engine();
const seen: Message[] = [];
engine.on('move', (m) => seen.push(m));
engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
engine.tick();
expect(seen).toHaveLength(1);
expect(seen[0]!.data).toEqual({ part: 'a', to: '/grid/5/5' });
});
it('a trigger reacts to a message and emits on the next tick', () => {
const engine = new Engine();
const seen: Message[] = [];
engine.on('move', (m) => seen.push(m));
engine.registerTrigger({
type: 'tap',
id: 'draw',
match: { part: 'carcassonne:tile#a' },
emit: [{ type: 'move', data: { part: 'carcassonne:tile#a', to: '/grid/5/5' } }],
});
engine.enqueue({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } });
engine.tick(); // tap processed; move emitted to the next tick
expect(seen).toHaveLength(0);
engine.tick(); // move runs
expect(seen).toHaveLength(1);
});
it('runCommand runs the command and emits its result message', async () => {
const engine = new Engine();
const results: Message[] = [];
engine.on('move:done', (m) => results.push(m));
engine.on('move:error', (m) => results.push(m));
engine.runCommand('move', async ({ args }) => {
expect(args).toEqual({ part: 'a', to: '/grid/5/5' });
return 'moved';
});
engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
engine.tick(); // command runs; result emitted to the next tick
await Promise.resolve(); // flush the command's async resolution
engine.tick(); // result processed
expect(results).toEqual([{ type: 'move:done', data: 'moved' }]);
});
it('runCommand emits :error when the command throws', async () => {
const engine = new Engine();
const results: Message[] = [];
engine.on('move:error', (m) => results.push(m));
engine.runCommand('move', async () => {
throw new Error('bad path');
});
engine.enqueue({ type: 'move', data: {} });
engine.tick();
await Promise.resolve();
await Promise.resolve(); // rejection hops through .then before .catch
engine.tick();
expect(results).toEqual([{ type: 'move:error', error: new Error('bad path') }]);
});
it('an orchestrator awaits a matching message and resumes on tick', async () => {
const engine = new Engine();
const log: string[] = [];
const done = engine.runOrchestrator(async (ctx) => {
log.push('start');
const tap = await ctx.wait((m) => m.type === 'tap');
log.push(`tap:${(tap.data as { part: string }).part}`);
ctx.emit({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
});
// Nothing enqueued yet — the orchestrator is suspended.
engine.tick();
expect(log).toEqual(['start']);
engine.enqueue({ type: 'tap', data: { part: 'carcassonne:tile#a' } });
engine.tick();
await done;
expect(log).toEqual(['start', 'tap:carcassonne:tile#a']);
// The move emitted by the orchestrator runs on the next tick.
engine.tick();
});
it('an orchestrator wait rejects when its signal aborts', async () => {
const engine = new Engine();
const controller = new AbortController();
const log: string[] = [];
const done = engine.runOrchestrator(
async (ctx) => {
try {
await ctx.wait((m) => m.type === 'tap');
log.push('resolved');
} catch {
log.push('aborted');
}
},
controller.signal,
);
controller.abort();
await done;
expect(log).toEqual(['aborted']);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* The engine: the message bus that ties the queue, triggers, and handlers
* together.
*
* The engine is pure no r3f, no React, no store. It defines the contract;
* `@tts/tabletop` registers the built-in commands (`move`, `focus`, `caption`,
* ...) that mutate the tabletop store and drive the render layer. The engine
* never imports tabletop.
*/
import { MessageQueue, type CommandResult, type Message, type MessageHandler } from './message.js';
import { TriggerRegistry, type Trigger } from './trigger.js';
import { runOrchestrator } from './orchestrator.js';
import { type Command, type Orchestrator, type RunContext } from './run.js';
/**
* The engine. `enqueue` adds a message; `tick` drains the queue and dispatches
* each message to the handlers registered for its `type`, then to matching
* triggers. Orchestrators run against the same queue and suspend on `wait`
* until a matching message is processed.
*/
export class Engine {
private queue = new MessageQueue();
private triggers = new TriggerRegistry();
private handlers = new Map<string, MessageHandler[]>();
constructor() {
// Every message goes to the handlers for its type (they run commands), then
// to triggers (they react to the message, including the `:done` a command
// emits). Emissions from either land on the next tick.
this.queue.on((msg) => {
for (const handler of this.handlers.get(msg.type) ?? []) handler(msg);
for (const t of this.triggers.match(msg)) {
for (const emit of t.emit) this.queue.enqueue(emit);
}
});
}
/** Register a handler for a message type. Returns an unsubscribe. */
on(type: string, handler: MessageHandler): () => void {
const list = this.handlers.get(type) ?? [];
list.push(handler);
this.handlers.set(type, list);
return () => {
const cur = this.handlers.get(type);
if (!cur) return;
const next = cur.filter((h) => h !== handler);
if (next.length) this.handlers.set(type, next);
else this.handlers.delete(type);
};
}
/**
* Register a command. The engine builds a `RunContext` from the message,
* runs the command, and emits its result `:done` on resolve, `:cancel` on
* abort, `:error` on throw.
*/
runCommand<Name extends string, Args, Result>(
type: Name,
command: Command<Args, Result>,
): () => void {
return this.on(type, (msg) => {
const controller = new AbortController();
const ctx: RunContext = {
signal: controller.signal,
emit: (m) => this.queue.enqueue(m),
wait: (pred) =>
new Promise<Message>((resolve, reject) => {
if (controller.signal.aborted) {
reject(new Error('aborted'));
return;
}
const off = this.on('*', (m) => {
if (pred(m)) {
off();
resolve(m);
}
});
controller.signal.addEventListener('abort', () => {
off();
reject(new Error('aborted'));
}, { once: true });
}),
enableTrigger: (type, id) => this.triggers.enable(type, id),
disableTrigger: (type, id) => this.triggers.disable(type, id),
};
const args = msg.data as Args;
command({ ...ctx, args })
.then((result) => this.queue.enqueue({ type: `${type}:done`, data: result } as CommandResult<Name, Result>))
.catch((err: unknown) => {
if (controller.signal.aborted) {
this.queue.enqueue({ type: `${type}:cancel` } as CommandResult<Name, Result>);
} else {
this.queue.enqueue({
type: `${type}:error`,
error: err instanceof Error ? err : new Error(String(err)),
} as CommandResult<Name, Result>);
}
});
});
}
enqueue(msg: Message): void {
this.queue.enqueue(msg);
}
/** Drain the queue and process the snapshot. Returns the processed count. */
tick(): number {
return this.queue.tick();
}
registerTrigger(t: Trigger): void {
this.triggers.register(t);
}
unregisterTrigger(t: Trigger): void {
this.triggers.unregister(t);
}
enableTrigger(type: string, id?: string): void {
this.triggers.enable(type, id);
}
disableTrigger(type: string, id?: string): void {
this.triggers.disable(type, id);
}
/** Run an orchestrator against this engine's queue. */
runOrchestrator(o: Orchestrator, signal?: AbortSignal): Promise<void> {
return runOrchestrator(
o,
(msg) => this.queue.enqueue(msg),
(handler) => this.queue.on(handler),
(type, id) => this.triggers.enable(type, id),
(type, id) => this.triggers.disable(type, id),
signal,
);
}
}
export type { Message, MessageHandler, CommandMessage, CommandResult } from './message.js';
export type { Trigger } from './trigger.js';
export { triggerMatches, TriggerRegistry } from './trigger.js';
export type { Orchestrator, RunContext, Command } from './run.js';
export { runOrchestrator } from './orchestrator.js';
+11
View File
@@ -0,0 +1,11 @@
export {
MessageQueue,
type Message,
type MessageHandler,
type CommandMessage,
type CommandResult,
} from './message.js';
export { TriggerRegistry, triggerMatches, type Trigger } from './trigger.js';
export { runOrchestrator } from './orchestrator.js';
export type { Orchestrator, RunContext, Command } from './run.js';
export { Engine } from './engine.js';
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { MessageQueue } from './message.js';
describe('MessageQueue', () => {
it('processes messages in FIFO order on tick', () => {
const q = new MessageQueue();
const seen: string[] = [];
q.on((m) => seen.push(m.type));
q.enqueue({ type: 'a' });
q.enqueue({ type: 'b' });
expect(q.size).toBe(2);
expect(q.tick()).toBe(2);
expect(seen).toEqual(['a', 'b']);
expect(q.size).toBe(0);
});
it('snapshots and drains: emissions during a drain go to the next tick', () => {
const q = new MessageQueue();
const seen: string[] = [];
q.on((m) => {
seen.push(m.type);
if (m.type === 'a') q.enqueue({ type: 'b' });
});
q.enqueue({ type: 'a' });
// The 'b' emitted during the drain must NOT be processed in the same tick.
expect(q.tick()).toBe(1);
expect(seen).toEqual(['a']);
expect(q.tick()).toBe(1);
expect(seen).toEqual(['a', 'b']);
});
it('unsubscribes a handler', () => {
const q = new MessageQueue();
const seen: string[] = [];
const off = q.on((m) => seen.push(m.type));
q.enqueue({ type: 'a' });
q.tick();
off();
q.enqueue({ type: 'b' });
q.tick();
expect(seen).toEqual(['a']);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* The message model and the queue that serializes it.
*
* A message is both an event (something happened) and an intent (something
* should happen). It is identified by `type`, matching the format's `type#id`
* convention. A `move` message both runs the move command and is observable as
* an event; the command's completion is itself a message (`move:done`), which
* is what triggers match and orchestrators await.
*
* Messages are a discriminated union on `type`. The engine defines the generic
* shapes; the host's concrete union extends them with its own command types.
*
* Messages are not processed inline. They are enqueued and handled on the next
* `tick()`. This kills reentrancy (a handler cannot cause unbounded
* recursion), gives a natural debounce, and makes the whole system a
* deterministic frame. The engine is pure it has no render loop so the
* host calls `tick()` (a `useFrame` in `@tts/tabletop`, manually in tests).
*/
/** A command message names a command to run. Its handler is the command. */
export interface CommandMessage<Name extends string, Args> {
type: Name;
data: Args;
}
/**
* A command's result, discriminated on the type suffix. `:done` on resolve,
* `:cancel` on abort (superseded, skipped, surface disabled), `:error` on
* throw. A trigger matching `move:done` does not fire on a cancel.
*/
export type CommandResult<Name extends string, R = void> =
| { type: `${Name}:done`; data: R }
| { type: `${Name}:cancel` }
| { type: `${Name}:error`; error: Error };
/**
* The base message type. The engine is host-agnostic, so this is a permissive
* structural type; the host defines a concrete discriminated union on `type`
* that extends it with its own command and interaction messages.
*/
export interface Message {
type: string;
/** Command-specific payload. */
data?: unknown;
/** Present on `:error` result messages. */
error?: Error;
}
/** A handler consumes a message and may emit new ones. */
export type MessageHandler = (msg: Message) => void;
/**
* A message queue. `enqueue` adds a message to the pending set; `tick` drains
* the snapshot and processes it. Messages emitted during a drain go to the
* next tick (snapshot-and-drain), so a handler can never re-enter mid-drain.
*/
export class MessageQueue {
private pending: Message[] = [];
private handlers: MessageHandler[] = [];
/** Register a handler for every message. Returns an unsubscribe. */
on(handler: MessageHandler): () => void {
this.handlers.push(handler);
return () => {
this.handlers = this.handlers.filter((h) => h !== handler);
};
}
enqueue(msg: Message): void {
this.pending.push(msg);
}
/** Drain the current snapshot and process it. Returns the processed count. */
tick(): number {
const batch = this.pending;
this.pending = [];
for (const msg of batch) {
for (const handler of this.handlers) {
handler(msg);
}
}
return batch.length;
}
/** The number of messages waiting to be processed. */
get size(): number {
return this.pending.length;
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Orchestrators imperative async flow.
*
* An orchestrator is the code counterpart to a trigger: an async function that
* emits messages and awaits matching ones. It is a proper TS module, declared
* per folder as `main.ts`, unique per folder like `package.yaml`, exported as a
* default async function.
*
* An orchestrator is a long-running command: it awaits events instead of
* resolving immediately, so it inherits the run-context machinery (supersede
* groups, cancellation, tap subscription) for free. Trigger control lives here
* declaration is data, activation is code.
*/
import type { Message } from './message.js';
import type { Orchestrator, RunContext } from './run.js';
/**
* Run an orchestrator against a queue. `emit` enqueues; `wait` suspends until
* a matching message is processed during a `tick()`, rejecting on abort.
* Returns a promise that resolves when the orchestrator completes.
*/
export function runOrchestrator(
orchestrator: Orchestrator,
emit: (msg: Message) => void,
on: (handler: (msg: Message) => void) => () => void,
enableTrigger: (type: string, id?: string) => void,
disableTrigger: (type: string, id?: string) => void,
signal?: AbortSignal,
): Promise<void> {
const controller = new AbortController();
const pending: Array<{ pred: (msg: Message) => boolean; resolve: (m: Message) => void; reject: (e: Error) => void }> = [];
const abort = () => {
controller.abort();
// Reject every pending wait so the orchestrator unwinds on cancel.
for (const p of pending.splice(0)) p.reject(new Error('aborted'));
};
if (signal?.aborted) abort();
else signal?.addEventListener('abort', abort, { once: true });
const unsubscribe = on((msg) => {
for (let i = 0; i < pending.length; i++) {
const p = pending[i]!;
if (p.pred(msg)) {
pending.splice(i, 1);
p.resolve(msg);
break;
}
}
});
const ctx: RunContext = {
signal: controller.signal,
emit,
wait: (pred) =>
new Promise<Message>((resolve, reject) => {
if (controller.signal.aborted) {
reject(new Error('aborted'));
return;
}
pending.push({ pred, resolve, reject });
}),
enableTrigger,
disableTrigger,
};
return orchestrator(ctx).finally(() => {
unsubscribe();
controller.abort();
});
}
+30
View File
@@ -0,0 +1,30 @@
/**
* The run context and the unified command/orchestrator shape.
*
* A command and an orchestrator are the same thing: an async function taking a
* `RunContext`. A command returns a result and is awaited by the engine, which
* emits `:done`/`:cancel`/`:error`; an orchestrator returns `void` and is never
* awaited by a parent. Cancellation is an `AbortSignal` a superseded command
* or disabled surface aborts it, `wait` rejects on abort, and the `:cancel`
* result is emitted. Errors are thrown: a command that throws emits `:error`.
*/
import type { Message } from './message.js';
/** The handle every handler runs against. */
export interface RunContext {
/** Cancellation: superseded, skipped, surface disabled. */
signal: AbortSignal;
/** Emit a message onto the queue. */
emit(msg: Message): void;
/** Await the next message matching `pred`. Rejects on abort. */
wait(pred: (m: Message) => boolean): Promise<Message>;
/** Enable/disable a trigger by `type#id`. */
enableTrigger(type: string, id?: string): void;
disableTrigger(type: string, id?: string): void;
}
/** A command: an async function taking the context plus its args. */
export type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
/** An orchestrator: an async function that emits and awaits messages. */
export type Orchestrator = (ctx: RunContext) => Promise<void>;
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { TriggerRegistry, triggerMatches, type Trigger } from './trigger.js';
const tap: Trigger = {
type: 'tap',
id: 'draw',
match: { part: 'carcassonne:tile#a', trigger: 'draw' },
emit: [{ type: 'move', data: { part: 'carcassonne:tile#a', to: '/grid/5/5' } }],
};
describe('triggerMatches', () => {
it('matches on type and every match param', () => {
expect(
triggerMatches(tap, { type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } }),
).toBe(true);
});
it('rejects a different type', () => {
expect(triggerMatches(tap, { type: 'focus' })).toBe(false);
});
it('rejects a mismatched param', () => {
expect(
triggerMatches(tap, { type: 'tap', data: { part: 'carcassonne:tile#b', trigger: 'draw' } }),
).toBe(false);
});
it('matches any message of the type when there is no match block', () => {
const any = { type: 'focus', emit: [] };
expect(triggerMatches(any, { type: 'focus', data: { path: '/deck' } })).toBe(true);
});
});
describe('TriggerRegistry', () => {
it('registers and matches enabled triggers', () => {
const reg = new TriggerRegistry();
reg.register(tap);
expect(reg.match({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } })).toEqual([
tap,
]);
});
it('collision-checks duplicate type#id', () => {
const reg = new TriggerRegistry();
reg.register(tap);
expect(() => reg.register({ ...tap })).toThrow(/Duplicate trigger: tap#draw/);
});
it('disable/enable toggles a trigger at runtime', () => {
const reg = new TriggerRegistry();
reg.register(tap);
reg.disable('tap', 'draw');
expect(reg.match({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } })).toEqual([]);
reg.enable('tap', 'draw');
expect(reg.match({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } })).toEqual([
tap,
]);
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Triggers declarative reactive glue.
*
* A trigger matches a message by `type` and named params, and emits messages
* in response. It is declared as data, keyed by `role+type+id` like other
* defs, and collision-checked the same way. `match` binds named params from
* the payload (like a route's candidates); `emit` uses the command-id-as-key
* convention. Multiple triggers can match the same message both fire.
*/
import type { Message } from './message.js';
export interface Trigger {
/** The message type this trigger matches. */
type: string;
/** Optional identity, for runtime enable/disable and collision checks. */
id?: string;
/** Named params that must equal the corresponding fields in `msg.data`. */
match?: Record<string, unknown>;
/** Messages to emit when the trigger matches. */
emit: Message[];
}
/** A trigger matches when its `type` and every `match` param line up. */
export function triggerMatches(t: Trigger, msg: Message): boolean {
if (t.type !== msg.type) return false;
if (!t.match) return true;
const data = msg.data as Record<string, unknown> | undefined;
if (!data) return false;
return Object.entries(t.match).every(([k, v]) => data[k] === v);
}
/**
* A registry of triggers, keyed by `type#id`. `register` collision-checks the
* key; `enable`/`disable` toggle a trigger at runtime (an orchestrator's
* "no more placements this turn" control). `match` returns every enabled
* trigger that matches a message.
*/
export class TriggerRegistry {
private triggers = new Map<string, Trigger>();
private enabled = new Set<string>();
register(t: Trigger): void {
const key = triggerKey(t);
if (this.triggers.has(key)) {
throw new Error(`Duplicate trigger: ${key}`);
}
this.triggers.set(key, t);
this.enabled.add(key);
}
unregister(t: Trigger): void {
const key = triggerKey(t);
this.triggers.delete(key);
this.enabled.delete(key);
}
enable(type: string, id?: string): void {
this.enabled.add(triggerKey({ type, id }));
}
disable(type: string, id?: string): void {
this.enabled.delete(triggerKey({ type, id }));
}
/** Every enabled trigger matching `msg`. */
match(msg: Message): Trigger[] {
const out: Trigger[] = [];
for (const [key, t] of this.triggers) {
if (this.enabled.has(key) && triggerMatches(t, msg)) out.push(t);
}
return out;
}
}
function triggerKey(t: Pick<Trigger, 'type' | 'id'>): string {
return t.id ? `${t.type}#${t.id}` : t.type;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}
+5
View File
@@ -23,4 +23,9 @@ export function resolveAssetUrl(url: string, baseUrl?: string): string {
/** Route an external asset URL through the proxy so three.js can load it. */ /** Route an external asset URL through the proxy so three.js can load it. */
export function assetUrl(url: string): string { export function assetUrl(url: string): string {
return `/asset?url=${encodeURIComponent(url)}`; return `/asset?url=${encodeURIComponent(url)}`;
}
/** Route a PDF URL through the proxy so it renders inline instead of downloading. */
export function pdfUrl(url: string): string {
return `/pdf?url=${encodeURIComponent(url)}`;
} }
+1 -1
View File
@@ -1,3 +1,3 @@
export { resolveAssetUrl, assetUrl } from './asset.js'; export { resolveAssetUrl, assetUrl, pdfUrl } from './asset.js';
export { traceImage } from './trace.js'; export { traceImage } from './trace.js';
export type { TraceResult } from '@tts/shared'; export type { TraceResult } from '@tts/shared';
+8
View File
@@ -87,6 +87,14 @@ export interface TTSMod {
ObjectStates: TTSObject[]; ObjectStates: TTSObject[];
} }
/** A parsed save plus its Workshop metadata, returned by `GET /items/:id`. */
export interface ModDetails {
mod: TTSMod;
/** Present when the metadata could be resolved via the Steam API. */
title?: string;
previewImageUrl?: string;
}
/** Metadata for a Workshop item, from the Steam Web API. */ /** Metadata for a Workshop item, from the Steam Web API. */
export interface WorkshopItem { export interface WorkshopItem {
id: string; id: string;
+38 -5
View File
@@ -1,5 +1,5 @@
import { deserialize } from 'bson'; import { deserialize } from 'bson';
import type { TTSMod } from '@tts/shared'; import type { ModDetails, TTSMod } from '@tts/shared';
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js'; import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
const STEAM_API_URL = const STEAM_API_URL =
@@ -36,6 +36,30 @@ export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
return fetchModFromUrl(fileUrl); return fetchModFromUrl(fileUrl);
} }
/**
* Fetch a save plus its Workshop metadata (title, preview image) in one call.
* The metadata comes from the same Steam API request that resolves the save
* URL, so it costs no extra round-trip.
*
* @param id Workshop item ID (digits only).
* @param apiKey Steam Web API key.
*/
export async function fetchModDetails(
id: string,
apiKey: string,
): Promise<ModDetails> {
const details = await getPublishedFileDetails(id, apiKey);
if (!details.file_url) {
throw new NoFileError(id);
}
const mod = await fetchModFromUrl(details.file_url);
return {
mod,
title: details.title,
previewImageUrl: details.preview_url,
};
}
/** /**
* Download a TTS save from a direct URL and BSON-deserialize it. * Download a TTS save from a direct URL and BSON-deserialize it.
* *
@@ -94,6 +118,18 @@ async function downloadSave(fileUrl: string): Promise<ArrayBuffer> {
/** Resolve the `file_url` for a Workshop item via the Steam API. */ /** Resolve the `file_url` for a Workshop item via the Steam API. */
async function getFileUrl(id: string, apiKey: string): Promise<string> { async function getFileUrl(id: string, apiKey: string): Promise<string> {
const details = await getPublishedFileDetails(id, apiKey);
if (!details.file_url) {
throw new NoFileError(id);
}
return details.file_url;
}
/** Fetch the Steam published-file details for a Workshop item. */
async function getPublishedFileDetails(
id: string,
apiKey: string,
): Promise<SteamPublishedFileDetails> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('key', apiKey); params.append('key', apiKey);
params.append('itemcount', '1'); params.append('itemcount', '1');
@@ -112,10 +148,7 @@ async function getFileUrl(id: string, apiKey: string): Promise<string> {
if (!details) { if (!details) {
throw new ItemNotFoundError(id); throw new ItemNotFoundError(id);
} }
if (!details.file_url) { return details;
throw new NoFileError(id);
}
return details.file_url;
} }
export * from './errors.js'; export * from './errors.js';
+6
View File
@@ -183,6 +183,12 @@ importers:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)) version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
packages/engine:
devDependencies:
typescript:
specifier: ^5.7.2
version: 5.9.3
packages/extract: packages/extract:
dependencies: dependencies:
'@tts/shared': '@tts/shared':