Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f01bb5f99b | ||
|
|
8ebb9211c5 | ||
|
|
a3095ead38 | ||
|
|
30ef76632f | ||
|
|
7163451d1f | ||
|
|
2430be9661 | ||
|
|
002bcb324b | ||
|
|
c665212209 | ||
|
|
2403abc07b | ||
|
|
ee7e73c798 | ||
|
|
dd08f901ae | ||
|
|
8bd186df54 | ||
|
|
81c115cb4d | ||
|
|
3167d26bd6 | ||
|
|
91cd3a16d7 |
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
+17
-3
@@ -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,9 +19,20 @@ 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
|
||||||
|
the sidebar can scroll independently and the viewer gets all the space. */}
|
||||||
|
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
|
||||||
|
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
|
||||||
|
{isModRoute ? (
|
||||||
|
<ModHeader />
|
||||||
|
) : (
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
<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">
|
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||||
TTS Workshop
|
TTS Workshop
|
||||||
@@ -34,8 +46,9 @@ export default function App() {
|
|||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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 />} />
|
||||||
@@ -58,5 +71,6 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+4
-4
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{types.map(({ name, count }) => {
|
||||||
|
const active = highlighted.has(name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => toggleType(name)}
|
||||||
|
aria-pressed={active}
|
||||||
|
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
|
<TreeNode
|
||||||
key={index}
|
key={path}
|
||||||
node={node}
|
node={node}
|
||||||
depth={0}
|
depth={0}
|
||||||
path={`${index}`}
|
path={path}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +169,50 @@ 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;
|
||||||
|
}
|
||||||
@@ -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]) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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,31 +39,9 @@ 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">
|
|
||||||
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
|
|
||||||
asset refs
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<a
|
|
||||||
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]">
|
|
||||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
|
|
||||||
<ObjectTree
|
<ObjectTree
|
||||||
nodes={tree}
|
nodes={tree}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
@@ -72,47 +49,28 @@ export default function ModPage() {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<section className="h-full min-h-0 flex-1 p-4">
|
||||||
{selected ? (
|
{selected && Viewer ? (
|
||||||
<>
|
/* Key by selection path so the Canvas remounts and the camera
|
||||||
<header className="mb-4">
|
refits to the newly selected object. */
|
||||||
<span
|
|
||||||
title={selected.object.Name}
|
|
||||||
className="inline-flex items-center gap-1 text-zinc-400"
|
|
||||||
>
|
|
||||||
{iconsForObject(selected.object.Name).map((icon) => (
|
|
||||||
<Icon key={icon} icon={icon} className="h-5 w-5" />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<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>
|
<ErrorBoundary>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
Loading viewer…
|
Loading viewer…
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Key by selection path so the Canvas remounts and the
|
<Viewer key={selectedPath} object={selected.object} fill />
|
||||||
camera refits to the newly selected object. */}
|
|
||||||
<Viewer key={selectedPath} object={selected.object} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-zinc-500">
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
Select an object from the tree to inspect it.
|
Select an object from the tree to inspect it.
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -300,3 +300,27 @@ 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.
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -24,3 +24,8 @@ export function resolveAssetUrl(url: string, baseUrl?: string): string {
|
|||||||
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,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';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
Generated
+6
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user