Compare commits
51
Commits
6502fdae4f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf6785f9e | ||
|
|
d0e4269ad6 | ||
|
|
999b7a0771 | ||
|
|
8eff44712f | ||
|
|
3813287489 | ||
|
|
71ae91960f | ||
|
|
addfc03ab6 | ||
|
|
211c151971 | ||
|
|
345832e389 | ||
|
|
cf8ca07850 | ||
|
|
498cf1b633 | ||
|
|
a9297d8b2b | ||
|
|
7d9b5e49ad | ||
|
|
3dd3db5643 | ||
|
|
fc9f756d52 | ||
|
|
7ff8e3c51d | ||
|
|
82a4f9b645 | ||
|
|
82d5a36497 | ||
|
|
699b89e916 | ||
|
|
0d35ad56f2 | ||
|
|
f01bb5f99b | ||
|
|
8ebb9211c5 | ||
|
|
a3095ead38 | ||
|
|
30ef76632f | ||
|
|
7163451d1f | ||
|
|
2430be9661 | ||
|
|
002bcb324b | ||
|
|
c665212209 | ||
|
|
2403abc07b | ||
|
|
ee7e73c798 | ||
|
|
dd08f901ae | ||
|
|
8bd186df54 | ||
|
|
81c115cb4d | ||
|
|
3167d26bd6 | ||
|
|
91cd3a16d7 | ||
|
|
13b3eaed78 | ||
|
|
23332ab786 | ||
|
|
aab0b66ee8 | ||
|
|
c41c266ac1 | ||
|
|
634a99dd25 | ||
|
|
9b5223686e | ||
|
|
3aa48058f7 | ||
|
|
812b4640e2 | ||
|
|
9d776771b1 | ||
|
|
d663f4afea | ||
|
|
aa23e93b3f | ||
|
|
77e0751554 | ||
|
|
dc721d5c2d | ||
|
|
96c299e498 | ||
|
|
c2693277ac | ||
|
|
bc418b2c73 |
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: commit-conventions
|
||||
description: How to write git commits for this repository. Use when committing changes, writing commit messages, or splitting work into commits. Covers conventional commit format and separating commits by concern.
|
||||
---
|
||||
|
||||
# Commit Conventions
|
||||
|
||||
Follow these rules whenever you create a commit in this repository.
|
||||
|
||||
## Conventional Commits
|
||||
|
||||
Write every commit message using the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
```
|
||||
|
||||
- **type**: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `style`.
|
||||
- **scope** (optional): the area of the codebase the change touches. Use the workspace package or directory name when it's clear (e.g. `proxy`, `web`, `packages`). Omit when the change spans multiple areas.
|
||||
- **subject**: imperative mood, capitalized, no trailing period, ≤ 50 characters.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
feat(proxy): add TTS streaming endpoint
|
||||
fix(web): handle empty transcript in player
|
||||
docs: document workspace layout
|
||||
```
|
||||
|
||||
## Separate Commits by Concern
|
||||
|
||||
Do not bundle unrelated changes into a single commit. Split work so each commit is a focused, self-contained unit:
|
||||
|
||||
- One logical change per commit (a feature, a fix, a refactor, a doc update).
|
||||
- Keep each commit buildable and independently reviewable.
|
||||
- Separate concerns that have different types, scopes, or reasons to be reverted independently.
|
||||
- If a change is large, split it into a series of smaller commits that each stand on their own.
|
||||
|
||||
## Message Body
|
||||
|
||||
Include a body only when it adds useful context beyond the subject. If the subject fully captures the change, omit it.
|
||||
|
||||
- Separate the subject from the body with a blank line.
|
||||
- Wrap the body at 72 characters.
|
||||
- Explain the *why* and *what* rather than restating the code.
|
||||
- Do not repeat information already in the subject line.
|
||||
- Use `BREAKING CHANGE:` in the body (or `!` after the type/scope) for breaking changes.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating a new commit.
|
||||
- Writing or revising a commit message.
|
||||
- Deciding how to split staged or unstaged changes into commits.
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated TTS save dumps (scripts/dump-save.mjs)
|
||||
scripts/dumps/
|
||||
scripts/dumps/
|
||||
|
||||
@@ -14,8 +14,9 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
|
||||
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
||||
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
||||
|
||||
See [`docs/architecture.md`](docs/architecture.md) for the architecture and
|
||||
[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan.
|
||||
See [`docs/overview.md`](docs/overview.md) for the docs index,
|
||||
[`docs/architecture.md`](docs/architecture.md) for the architecture, and
|
||||
[`docs/status/implementation-plan.md`](docs/status/implementation-plan.md) for the plan.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setGamesRoot } from './config.js';
|
||||
import asset from './routes/asset.js';
|
||||
import health from './routes/health.js';
|
||||
import items from './routes/items.js';
|
||||
import pdf from './routes/pdf.js';
|
||||
import search from './routes/search.js';
|
||||
import trace from './routes/trace.js';
|
||||
|
||||
@@ -27,6 +28,7 @@ app.route('/health', health);
|
||||
app.route('/search', search);
|
||||
app.route('/items', items);
|
||||
app.route('/asset', asset);
|
||||
app.route('/pdf', pdf);
|
||||
app.route('/trace', trace);
|
||||
|
||||
serve(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
||||
import type { TTSMod } from '@tts/shared';
|
||||
import type { ModDetails } from '@tts/shared';
|
||||
import items from './items.js';
|
||||
|
||||
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
||||
|
||||
const mod = {
|
||||
GameMode: 'Tabletop',
|
||||
Date: '2024-01-01',
|
||||
ObjectStates: [],
|
||||
};
|
||||
|
||||
const details: ModDetails = { mod };
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -17,7 +25,7 @@ describe('items route', () => {
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
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 () => {
|
||||
const mod: TTSMod = {
|
||||
GameMode: 'Tabletop',
|
||||
Date: '2024-01-01',
|
||||
ObjectStates: [],
|
||||
};
|
||||
const fetchModFromUrl = vi
|
||||
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
||||
.mockResolvedValue(mod);
|
||||
@@ -40,26 +43,26 @@ describe('items route', () => {
|
||||
{ STEAM_API_KEY: '', PORT: 3000 },
|
||||
);
|
||||
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');
|
||||
});
|
||||
|
||||
it('returns the parsed mod on success', async () => {
|
||||
const mod: TTSMod = {
|
||||
GameMode: 'Tabletop',
|
||||
Date: '2024-01-01',
|
||||
ObjectStates: [],
|
||||
it('returns the parsed mod with metadata on success', async () => {
|
||||
const withMeta: ModDetails = {
|
||||
mod,
|
||||
title: 'My Mod',
|
||||
previewImageUrl: 'https://example.com/preview.png',
|
||||
};
|
||||
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);
|
||||
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 () => {
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||
new ItemNotFoundError('123'),
|
||||
);
|
||||
const res = await items.request('/123', {}, env);
|
||||
@@ -70,7 +73,7 @@ describe('items route', () => {
|
||||
});
|
||||
|
||||
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'),
|
||||
);
|
||||
const res = await items.request('/123', {}, env);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
||||
import { itemIdSchema, type ModDetails } from '@tts/shared';
|
||||
import {
|
||||
fetchMod,
|
||||
fetchModDetails,
|
||||
fetchModFile,
|
||||
fetchModFileFromUrl,
|
||||
fetchModFromUrl,
|
||||
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
|
||||
try {
|
||||
const fileUrl = c.req.query('fileUrl');
|
||||
// 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.
|
||||
const mod: TTSMod = fileUrl
|
||||
? await fetchModFromUrl(fileUrl)
|
||||
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
|
||||
return c.json<TTSMod>(mod);
|
||||
// search result). Otherwise resolve it via the Steam API, which also
|
||||
// yields the Workshop title and preview image.
|
||||
const details: ModDetails = fileUrl
|
||||
? { mod: await fetchModFromUrl(fileUrl) }
|
||||
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
|
||||
return c.json<ModDetails>(details);
|
||||
} catch (err) {
|
||||
if (err instanceof TtsError) {
|
||||
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;
|
||||
@@ -25,6 +25,7 @@
|
||||
"@tts/http": "workspace:*",
|
||||
"@tts/tabletop": "workspace:*",
|
||||
"bson": "^7.3.1",
|
||||
"postprocessing": "^6.36.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
|
||||
+29
-15
@@ -1,7 +1,8 @@
|
||||
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 ModPage from './pages/ModPage';
|
||||
import ModHeader from './components/ModHeader';
|
||||
|
||||
// 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
|
||||
@@ -18,24 +19,36 @@ const SetupsPage = lazy(() => import('./pages/SetupsPage'));
|
||||
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="border-b border-zinc-800">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||
TTS Workshop
|
||||
</Link>
|
||||
<nav className="flex gap-4 text-sm text-zinc-400">
|
||||
<Link to="/" className="hover:text-zinc-100">
|
||||
Search
|
||||
{/* 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">
|
||||
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||
TTS Workshop
|
||||
</Link>
|
||||
<Link to="/bgm" className="hover:text-zinc-100">
|
||||
BGM
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<nav className="flex gap-4 text-sm text-zinc-400">
|
||||
<Link to="/" className="hover:text-zinc-100">
|
||||
Search
|
||||
</Link>
|
||||
<Link to="/bgm" className="hover:text-zinc-100">
|
||||
BGM
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<Route path="/" element={<SearchPage />} />
|
||||
<Route path="/mod/:id" element={<ModPage />} />
|
||||
@@ -57,6 +70,7 @@ export default function App() {
|
||||
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
||||
</Routes>
|
||||
</main>
|
||||
</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';
|
||||
|
||||
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();
|
||||
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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">
|
||||
<Link to="/" className="hover:text-zinc-100">
|
||||
Search
|
||||
</Link>
|
||||
<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 type { ObjectTreeNode } from '@tts/extract';
|
||||
import { iconsForObject } from './objectIcons';
|
||||
@@ -10,19 +10,85 @@ interface 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 (
|
||||
<ul className="space-y-0.5">
|
||||
{nodes.map((node, index) => (
|
||||
<TreeNode
|
||||
key={index}
|
||||
node={node}
|
||||
depth={0}
|
||||
path={`${index}`}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{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
|
||||
key={path}
|
||||
node={node}
|
||||
depth={0}
|
||||
path={path}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +150,16 @@ function TreeNode({
|
||||
))}
|
||||
</span>
|
||||
<span className="truncate">{node.label}</span>
|
||||
{hasChildren && (
|
||||
<span
|
||||
title={`${node.children.length} ${
|
||||
node.children.length === 1 ? 'child' : 'children'
|
||||
}`}
|
||||
className="ml-1.5 shrink-0 rounded bg-zinc-800 px-1 font-mono text-[10px] leading-4 text-zinc-500"
|
||||
>
|
||||
{node.children.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
@@ -102,4 +178,51 @@ function TreeNode({
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
@@ -56,6 +56,9 @@ export default function TabletopScene({
|
||||
enablePan
|
||||
fullscreen
|
||||
shadowScale={shadowScale}
|
||||
// Keep the camera above the table so face-down cards can't be peeked
|
||||
// from below. π/2 clamps the polar angle at the horizon.
|
||||
maxPolarAngle={Math.PI / 2}
|
||||
overlay={
|
||||
<button
|
||||
onClick={() => setShowSurface((v) => !v)}
|
||||
|
||||
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
|
||||
export interface ObjectViewer {
|
||||
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
||||
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']>();
|
||||
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
|
||||
}
|
||||
|
||||
/** The default viewer: a plain inspection of the object's fields. */
|
||||
export function DefaultViewer({ object }: { object: TTSObject }) {
|
||||
export function DefaultViewer({ object }: ViewerProps) {
|
||||
return (
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
{Object.entries(object).map(([key, value]) => {
|
||||
|
||||
@@ -9,15 +9,21 @@ import {
|
||||
} from '@tts/mesh';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||
import { flipTexture } from './flipTexture';
|
||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
||||
import { applyMapTransform } from './cardMaterial';
|
||||
import {
|
||||
getSharedGeometry,
|
||||
getSharedMaterial,
|
||||
objectTint,
|
||||
tintKey,
|
||||
tintedColor,
|
||||
} from './sharedResources';
|
||||
|
||||
/** Longer card dimension, in world units. */
|
||||
const CARD_LENGTH = 2;
|
||||
/** Corner radius as a fraction of the shorter card edge. */
|
||||
const CORNER_RADIUS = 0.05;
|
||||
/** Thickness of the card. */
|
||||
const CARD_THICKNESS = 0.06;
|
||||
/** Thickness of the card, as a fraction of its length (real cards are ~0.3%). */
|
||||
const CARD_THICKNESS = 0.01;
|
||||
|
||||
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
||||
// Without it, the face/back hooks would be called conditionally, which breaks
|
||||
@@ -72,37 +78,68 @@ export function CardMesh({
|
||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||
|
||||
// Front texture: the sprite cell from the sheet (or the full image when there
|
||||
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
|
||||
// that share the same sheet URL (drei caches textures globally by URL).
|
||||
const faceMap = useMemo(() => {
|
||||
if (!faceUrl) return null;
|
||||
const tex = face.clone();
|
||||
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
|
||||
tex.repeat.set(repeatX, repeatY);
|
||||
tex.offset.set(offsetX, offsetY);
|
||||
return tex;
|
||||
}, [faceUrl, face, cardId, numWidth, numHeight]);
|
||||
// Card art is sRGB-encoded. `TextureLoader` leaves `colorSpace` as
|
||||
// `NoColorSpace`, which uploads the texture as linear and then double-decodes
|
||||
// it in the shader, washing out contrast. Mark it sRGB so the GPU decodes it
|
||||
// once, correctly. Clones (e.g. `flipTexture`) inherit this from the source.
|
||||
face.colorSpace = THREE.SRGBColorSpace;
|
||||
back.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
// Back texture: a single full image (tile) unless the deck has unique backs,
|
||||
// in which case it's a sheet too. Flipped left/right so it reads correctly
|
||||
// instead of being mirrored on the back face.
|
||||
const backMap = useMemo(() => {
|
||||
if (!backUrl) return null;
|
||||
const tex = back.clone();
|
||||
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
|
||||
// The face/back textures are shared (drei caches them by URL); each card's
|
||||
// sprite cell is selected via a per-material UV transform injected into the
|
||||
// shader, so no per-card texture clone (and no re-upload) is needed. The
|
||||
// transform is baked into the material's shader, so it must be keyed into the
|
||||
// shared-material cache to avoid mutating a material used by another card.
|
||||
const faceMap = faceUrl ? face : null;
|
||||
const backMap = backUrl ? back : null;
|
||||
|
||||
const tintK = tintKey(tint);
|
||||
const faceUv = faceUrl ? spriteUv(cardId, numWidth, numHeight) : null;
|
||||
const backUv = backUrl
|
||||
? uniqueBack
|
||||
? spriteUv(cardId, numWidth, numHeight)
|
||||
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
||||
tex.repeat.set(repeatX, repeatY);
|
||||
tex.offset.set(offsetX, offsetY);
|
||||
return flipTexture(tex);
|
||||
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
|
||||
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
|
||||
: null;
|
||||
|
||||
// Key by URL + card id + tint: the URL disambiguates different sheets (and
|
||||
// `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
|
||||
// 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
|
||||
// same size so the full-setup view reuses it; the face/back materials stay
|
||||
// per-card because each card clones its texture for sprite UVs.
|
||||
// same size so the full-setup view reuses it; the face/back materials are
|
||||
// shared per card (keyed by card id + tint) and carry the sprite UV transform.
|
||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
||||
| HTMLImageElement
|
||||
@@ -124,23 +161,9 @@ export function CardMesh({
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={frontGeo}>
|
||||
<meshStandardMaterial
|
||||
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||
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>
|
||||
<mesh geometry={frontGeo} material={faceMat} />
|
||||
<mesh geometry={backGeo} material={backMat} />
|
||||
<mesh geometry={wallsGeo} material={wallMat} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import Scene from './Scene';
|
||||
import { CardObjectMesh } from './CardMesh';
|
||||
import type { ViewerProps } from '../viewers';
|
||||
|
||||
/**
|
||||
* A playing card: a thin rounded rect with the face texture on the front and
|
||||
@@ -24,10 +25,15 @@ import { CardObjectMesh } from './CardMesh';
|
||||
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
||||
* the card.
|
||||
*/
|
||||
export default function CardViewer({ object }: { object: TTSObject }) {
|
||||
export default function CardViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene>
|
||||
<CardObjectMesh object={object} />
|
||||
<Scene fill={fill}>
|
||||
{/* Cards are authored standing (face in XY, thickness along Z); lay them
|
||||
flat on the ground so the face points up like a card on a table, which
|
||||
is also what the contact shadow needs to project. */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<CardObjectMesh object={object} />
|
||||
</group>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||
const texture = useTexture(assetUrl(url));
|
||||
|
||||
// Diffuse art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||
useLayoutEffect(() => {
|
||||
root.traverse((child) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import Scene from './Scene';
|
||||
import { CustomModelMesh } from './CustomModelMesh';
|
||||
import type { ViewerProps } from '../viewers';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* model's materials when present.
|
||||
*/
|
||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
||||
export default function CustomModelViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene>
|
||||
<Scene fill={fill}>
|
||||
<CustomModelMesh object={object} />
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import type { RefObject, WheelEvent } 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}
|
||||
shadow={false}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// No contained cards: show the deck object itself, laid flat so the
|
||||
// shared scene's contact shadow has a surface to project onto.
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<CardObjectMesh object={object} />
|
||||
</group>
|
||||
)}
|
||||
</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} />
|
||||
{/* A soft radial shadow under this card; it inherits the card group's
|
||||
position/rotation/scale, so it tweens with the card. Card meshes are
|
||||
laid flat (face up), so the plane sits just below the card's bottom. */}
|
||||
<CardShadow />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// A soft elliptical drop shadow under a single carousel card. The card mesh is
|
||||
// ~2 world-units tall (centered), so its bottom sits at local y = -1; we put a
|
||||
// radial-gradient plane just below it. I can't use `ContactShadows` here
|
||||
// because the card is a thin vertical panel — a top-down capture would only
|
||||
// catch a line. Instead we fake a soft ambient contact shadow with a gradient.
|
||||
const CARD_HALF_HEIGHT = 1;
|
||||
const SHADOW_SCALE = 2.6;
|
||||
|
||||
function CardShadow() {
|
||||
// A soft radial gradient, dark at the center fading to transparent — reads as
|
||||
// a soft blob of shadow rather than a hard cast shadow.
|
||||
const texture = useMemo(makeSoftShadowTexture, []);
|
||||
return (
|
||||
<mesh
|
||||
position={[0, -CARD_HALF_HEIGHT, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
scale={[SHADOW_SCALE, SHADOW_SCALE, 1]}
|
||||
>
|
||||
<planeGeometry args={[1, 1]} />
|
||||
<meshBasicMaterial map={texture} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a radial-gradient texture: dark center fading out to transparent. */
|
||||
function makeSoftShadowTexture(): THREE.Texture {
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return new THREE.Texture();
|
||||
const g = ctx.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size / 2,
|
||||
);
|
||||
const stops: [number, number][] = [
|
||||
[0, 0.55],
|
||||
[0.45, 0.35],
|
||||
[0.75, 0.12],
|
||||
[1, 0],
|
||||
];
|
||||
for (const [t, a] of stops) {
|
||||
g.addColorStop(t, `rgba(0, 0, 0, ${a})`);
|
||||
}
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
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 sliding-dot pagination, rendered as the scene
|
||||
* overlay. All dots live in a fixed-width track that slides so the active dot
|
||||
* stays centered; dots fade out as they leave the window. A counter above the
|
||||
* bar shows the current index (`1 / 51`). Round first/last and prev/next
|
||||
* buttons flank the dots. Scrolling over the pagination steps through the
|
||||
* deck.
|
||||
*/
|
||||
function CarouselControls({
|
||||
active,
|
||||
count,
|
||||
onStep,
|
||||
}: {
|
||||
active: number;
|
||||
count: number;
|
||||
onStep: (dir: number) => void;
|
||||
}) {
|
||||
const prev = () => onStep(-1);
|
||||
const next = () => onStep(1);
|
||||
const first = () => onStep(-active);
|
||||
const last = () => onStep(count - 1 - active);
|
||||
|
||||
// Dots visible on each side of the active dot.
|
||||
const DOT_WINDOW = 4;
|
||||
const DOT_SIZE = 8;
|
||||
const ACTIVE_WIDTH = 26;
|
||||
const GAP = 6;
|
||||
const SLOTS = DOT_WINDOW * 2 + 1;
|
||||
const slotW = DOT_SIZE + GAP;
|
||||
// Extra width the active dot (plus its gap) pushes beyond a normal slot.
|
||||
const extra = ACTIVE_WIDTH + GAP - slotW;
|
||||
const trackW = SLOTS * slotW + extra;
|
||||
// Left offset of a dot at slot `s` (0 = leftmost). Dots after the active
|
||||
// slot shift right by `extra` to make room for the wider active dot.
|
||||
const slotLeft = (s: number) => s * slotW + (s > DOT_WINDOW ? extra : 0);
|
||||
const dots = Array.from({ length: count }, (_, i) => i);
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
// Scroll up/left goes back, scroll down/right goes forward.
|
||||
onStep(e.deltaY < 0 ? -1 : 1);
|
||||
};
|
||||
|
||||
const roundBtn =
|
||||
'flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-zinc-900/80 disabled:hover:text-zinc-300';
|
||||
|
||||
return (
|
||||
<div className="absolute inset-x-0 bottom-2 z-10 flex flex-col items-center gap-2">
|
||||
<span className="rounded-md bg-zinc-900/80 px-2 py-0.5 font-mono text-xs text-zinc-300 backdrop-blur">
|
||||
{active + 1} / {count}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={first}
|
||||
disabled={active === 0}
|
||||
aria-label="First card"
|
||||
className={roundBtn}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button onClick={prev} aria-label="Previous card" className={roundBtn}>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
onWheel={onWheel}
|
||||
role="group"
|
||||
aria-label="Browse cards"
|
||||
className="flex items-center rounded-full bg-zinc-900/80 px-2 py-1.5 backdrop-blur"
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: trackW, height: DOT_SIZE }}
|
||||
>
|
||||
{dots.map((i) => {
|
||||
const isActive = i === active;
|
||||
const slot = DOT_WINDOW + (i - active);
|
||||
const inWindow = slot >= 0 && slot < SLOTS;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onStep(i - active)}
|
||||
aria-label={`Go to card ${i + 1}`}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className="absolute top-0 flex items-center justify-center rounded-full transition-all duration-300"
|
||||
style={{
|
||||
left: slotLeft(slot),
|
||||
width: isActive ? ACTIVE_WIDTH : DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
opacity: inWindow ? 1 : 0,
|
||||
pointerEvents: inWindow ? 'auto' : 'none',
|
||||
backgroundColor: isActive ? '#f4f4f5' : '#71717a',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={next} aria-label="Next card" className={roundBtn}>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={last}
|
||||
disabled={active === count - 1}
|
||||
aria-label="Last card"
|
||||
className={roundBtn}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</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="flex h-full min-h-0 flex-col gap-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="min-h-0 w-full flex-1 rounded-lg border border-zinc-800 bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState, type RefObject, type ReactNode } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
||||
import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
|
||||
import { BrightnessContrast, EffectComposer, ToneMapping, Vignette } from '@react-three/postprocessing';
|
||||
import { ToneMappingMode } from 'postprocessing';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
||||
@@ -16,27 +18,55 @@ import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
||||
*
|
||||
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
||||
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
||||
* expands the scene to the full screen.
|
||||
* `maxPolarAngle` (radians) clamps how
|
||||
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
|
||||
* 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({
|
||||
children,
|
||||
autoRotate = true,
|
||||
enablePan = false,
|
||||
fullscreen = false,
|
||||
fit = true,
|
||||
fill = false,
|
||||
overlay,
|
||||
shadowScale = 22,
|
||||
maxPolarAngle = Math.PI,
|
||||
shadowBlur = 0.012,
|
||||
shadow = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
autoRotate?: boolean;
|
||||
enablePan?: 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. */
|
||||
overlay?: ReactNode;
|
||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||
shadowScale?: number;
|
||||
/** Contact shadow blur as a fraction of the plane size; a larger value is softer. */
|
||||
shadowBlur?: number;
|
||||
/** Whether to render a scene-level contact shadow under the content (default true). */
|
||||
shadow?: boolean;
|
||||
/** Max camera polar angle in radians; defaults to unrestricted (π). */
|
||||
maxPolarAngle?: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Points at the wrapped children so the shadow can measure the real content
|
||||
// bounding box (independent of drei's `Bounds` camera fit).
|
||||
const contentRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
@@ -52,7 +82,9 @@ export default function Scene({
|
||||
return (
|
||||
<div
|
||||
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 />
|
||||
{fullscreen && (
|
||||
@@ -71,33 +103,38 @@ export default function Scene({
|
||||
dpr={[1, 2]}
|
||||
gl={{ antialias: true, alpha: true }}
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
||||
<ambientLight intensity={0.1} />
|
||||
|
||||
<Environment resolution={256}>
|
||||
<Lightformer intensity={.5} position={[0, 5, 0]} scale={[10, 10, 1]} />
|
||||
<Lightformer intensity={.5} position={[0, 0, 5]} scale={[10, 10, 1]} />
|
||||
<Lightformer intensity={.5} position={[0, 0, -5]} scale={[10, 10, 1]} />
|
||||
<Lightformer intensity={.5} position={[-5, 0, 0]} scale={[10, 10, 1]} />
|
||||
<Lightformer intensity={.5} position={[5, 0, -0]} scale={[10, 10, 1]} />
|
||||
<Lightformer intensity={0.6} color="#fff1d6" position={[0, -3, 0]} scale={[6, 6, 1]} />
|
||||
</Environment>
|
||||
|
||||
{shadow && (
|
||||
<AdaptiveContactShadow shadowScale={shadowScale} shadowBlur={shadowBlur} contentRef={contentRef} />
|
||||
)}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Bounds fit observe clip>{children}</Bounds>
|
||||
<Bounds fit={fit} observe={fit} clip={fit}>
|
||||
<group ref={contentRef}>{children}</group>
|
||||
</Bounds>
|
||||
</Suspense>
|
||||
|
||||
<ContactShadows
|
||||
position={[0, -0.01, 0]}
|
||||
opacity={0.2}
|
||||
scale={shadowScale}
|
||||
blur={shadowScale * 0.005}
|
||||
far={shadowScale * 0.01}
|
||||
resolution={1024}
|
||||
color="#000000"
|
||||
/>
|
||||
<OrbitControls
|
||||
enablePan={enablePan}
|
||||
minDistance={0.01}
|
||||
maxDistance={8}
|
||||
maxPolarAngle={maxPolarAngle}
|
||||
autoRotate={autoRotate}
|
||||
makeDefault
|
||||
/>
|
||||
|
||||
<EffectComposer>
|
||||
<ToneMapping mode={ToneMappingMode.AGX} />
|
||||
<BrightnessContrast brightness={0.12} contrast={.5} />
|
||||
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
||||
</EffectComposer>
|
||||
</Canvas>
|
||||
@@ -125,3 +162,84 @@ function LoadingOverlay() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact shadow sized and placed to the scene's actual content bounds.
|
||||
*
|
||||
* A fixed-size shadow plane baked for a 2-unit token is invisible to a whole
|
||||
* card or model ten times larger, so the plane is (re)sized to the bounding
|
||||
* box of whatever is wrapped in the scene, measured each frame from a ref.
|
||||
* Keeping it scene-level (instead of per mesh) keeps it world-horizontal at
|
||||
* the table and costs one shadow render per frame even when there are many
|
||||
* meshes, which per-mesh planes would multiply.
|
||||
*
|
||||
* The plane sits on the very bottom (y minimum) of the content, centered on
|
||||
* its footprint, and spans a bit wider so the shadow edge doesn't fall inside
|
||||
* the object. `far` covers the full content height so tall objects aren't
|
||||
* clipped. Falls back to `shadowScale` while the bounds are still empty.
|
||||
*/
|
||||
function AdaptiveContactShadow({
|
||||
shadowScale,
|
||||
shadowBlur,
|
||||
contentRef,
|
||||
}: {
|
||||
shadowScale: number;
|
||||
shadowBlur: number;
|
||||
contentRef: RefObject<THREE.Group | null>;
|
||||
}) {
|
||||
// Current computed shadow config, kept in a ref so it's stable between frames
|
||||
// and only recomputed when the content bounds actually change.
|
||||
const state = useRef({ scale: 0, posX: 0, posY: 0, posZ: 0, far: 0 });
|
||||
// Scratch box, reused each frame to avoid allocations.
|
||||
const box = useRef(new THREE.Box3()).current;
|
||||
|
||||
useFrame(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
content.updateWorldMatrix(true, true);
|
||||
box.setFromObject(content);
|
||||
if (box.isEmpty()) return;
|
||||
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
// The shadow plane rests on the very bottom of the content, centered on its
|
||||
// footprint. `far` covers the full content height (plus margin) so the
|
||||
// capture doesn't clip tall objects; `scale` covers the footprint.
|
||||
const scale = Math.max(size.x, size.z) * SHADOW_PAD;
|
||||
const posY = box.min.y;
|
||||
const far = size.y + SHADOW_HEIGHT_MARGIN;
|
||||
const s = state.current;
|
||||
if (
|
||||
scale !== s.scale ||
|
||||
center.x !== s.posX ||
|
||||
posY !== s.posY ||
|
||||
center.z !== s.posZ ||
|
||||
far !== s.far
|
||||
) {
|
||||
Object.assign(s, { scale, posX: center.x, posY, posZ: center.z, far });
|
||||
}
|
||||
});
|
||||
|
||||
const s = state.current;
|
||||
const scale = s.scale || shadowScale;
|
||||
// Sit just below the lowest point of the content so it rests on it without
|
||||
// intersecting; the plane uses a (fixed) world-horizontal orientation.
|
||||
const position = [s.posX, s.posY - 0.01, s.posZ] as [number, number, number];
|
||||
|
||||
return (
|
||||
<ContactShadows
|
||||
position={position}
|
||||
opacity={0.2}
|
||||
scale={scale}
|
||||
blur={scale * shadowBlur}
|
||||
far={s.far || scale * 0.01}
|
||||
resolution={1024}
|
||||
color="#000000"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** How far the shadow plane extends past the content footprint, as a multiple. */
|
||||
const SHADOW_PAD = 1.1;
|
||||
/** Extra capture depth beyond the content height, so the falloff edge isn't clipped. */
|
||||
const SHADOW_HEIGHT_MARGIN = 0.5;
|
||||
|
||||
@@ -69,6 +69,11 @@ export function TileMesh({
|
||||
}) {
|
||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||
|
||||
// Tile art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
// Build the extruded geometry from the tile shape. When `stretch` is false
|
||||
// and a texture is available, scale the shape to the image's aspect ratio so
|
||||
// the tile matches the source proportions instead of being square. Shared
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import Scene from './Scene';
|
||||
import { TileObjectMesh } from './TileMesh';
|
||||
import type { ViewerProps } from '../viewers';
|
||||
|
||||
/**
|
||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||
@@ -10,10 +11,13 @@ import { TileObjectMesh } from './TileMesh';
|
||||
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
||||
* source image instead of being forced square.
|
||||
*/
|
||||
export default function TileViewer({ object }: { object: TTSObject }) {
|
||||
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene>
|
||||
<TileObjectMesh object={object} />
|
||||
<Scene fill={fill}>
|
||||
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<TileObjectMesh object={object} />
|
||||
</group>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export function TokenMesh({
|
||||
}) {
|
||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||
|
||||
// Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||
// once the token geometry is present. Falls back to a circle when there's no
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import Scene from './Scene';
|
||||
import { TokenObjectMesh } from './TokenMesh';
|
||||
import type { ViewerProps } from '../viewers';
|
||||
|
||||
/**
|
||||
* A token: a short extruded shape with the texture on its top face. Uses
|
||||
@@ -8,10 +9,13 @@ import { TokenObjectMesh } from './TokenMesh';
|
||||
* 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.
|
||||
*/
|
||||
export default function TokenViewer({ object }: { object: TTSObject }) {
|
||||
export default function TokenViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene>
|
||||
<TokenObjectMesh object={object} />
|
||||
<Scene fill={fill}>
|
||||
{/* Lay the token flat on the ground (see CardViewer). */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<TokenObjectMesh object={object} />
|
||||
</group>
|
||||
</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 TokenViewer = lazy(() => import('./TokenViewer'));
|
||||
const CardViewer = lazy(() => import('./CardViewer'));
|
||||
const DeckViewer = lazy(() => import('./DeckViewer'));
|
||||
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
||||
const PdfViewer = lazy(() => import('./PdfViewer'));
|
||||
|
||||
registerViewer('Tile', TileViewer);
|
||||
registerViewer('Custom_Tile', TileViewer);
|
||||
registerViewer('Custom_Token', TokenViewer);
|
||||
registerViewer('Card', CardViewer);
|
||||
registerViewer('CardCustom', CardViewer);
|
||||
registerViewer('Deck', CardViewer);
|
||||
registerViewer('DeckCustom', CardViewer);
|
||||
registerViewer('Custom_Deck', CardViewer);
|
||||
registerViewer('Deck', DeckViewer);
|
||||
registerViewer('DeckCustom', DeckViewer);
|
||||
registerViewer('Custom_Deck', DeckViewer);
|
||||
registerViewer('Custom_Model', CustomModelViewer);
|
||||
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
||||
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
||||
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
||||
registerViewer('Custom_PDF', PdfViewer);
|
||||
@@ -30,7 +30,7 @@ export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
|
||||
* the same image.
|
||||
*
|
||||
* These caches live for the session (like drei's global texture cache) and are
|
||||
* not disposed on unmount; see `docs/full-setup-view.md`.
|
||||
* not disposed on unmount; see `docs/status/full-setup-view.md`.
|
||||
*/
|
||||
|
||||
const geometryCache = new Map<string, THREE.BufferGeometry>();
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
|
||||
}, [id, item?.fileUrl, load]);
|
||||
|
||||
const objects = useMemo(
|
||||
() => (mod ? flattenObjects(mod) : []),
|
||||
() => (mod ? flattenObjects(mod.mod) : []),
|
||||
[mod],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
import ObjectTree from '../components/ObjectTree';
|
||||
import { ErrorBoundary } from '@tts/tabletop';
|
||||
import { resolveViewer } from '../components/viewers';
|
||||
@@ -25,8 +24,8 @@ export default function ModPage() {
|
||||
if (id) load(id, item?.fileUrl);
|
||||
}, [id, item?.fileUrl, load]);
|
||||
|
||||
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
|
||||
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
|
||||
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
||||
@@ -40,78 +39,37 @@ export default function ModPage() {
|
||||
const Viewer = selected ? resolveViewer(selected.object) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Mod {id}</h1>
|
||||
<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="flex h-full min-h-0">
|
||||
{/* Independently scrolling tree; the filter bar stays pinned above it. */}
|
||||
<aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
|
||||
<ObjectTree
|
||||
nodes={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<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
|
||||
nodes={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
{selected ? (
|
||||
<>
|
||||
<header className="mb-4">
|
||||
<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>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
||||
Loading viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Key by selection path so the Canvas remounts and the
|
||||
camera refits to the newly selected object. */}
|
||||
<Viewer key={selectedPath} object={selected.object} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Select an object from the tree to inspect it.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<section className="h-full min-h-0 flex-1 p-4">
|
||||
{selected && Viewer ? (
|
||||
/* Key by selection path so the Canvas remounts and the camera
|
||||
refits to the newly selected object. */
|
||||
<ErrorBoundary key={selectedPath}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
Loading viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Viewer object={selected.object} fill />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
Select an object from the tree to inspect it.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TTSMod } from '@tts/shared';
|
||||
import type { ModDetails } from '@tts/shared';
|
||||
import { fetchMod } from '../api';
|
||||
|
||||
interface ModState {
|
||||
id: string | null;
|
||||
fileUrl?: string;
|
||||
mod: TTSMod | null;
|
||||
mod: ModDetails | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
load: (id: string, fileUrl?: string) => Promise<void>;
|
||||
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
|
||||
load: async (id, fileUrl) => {
|
||||
set({ loading: true, error: null, id, fileUrl });
|
||||
try {
|
||||
const mod = await fetchMod(id, fileUrl);
|
||||
set({ mod, loading: false });
|
||||
const details = await fetchMod(id, fileUrl);
|
||||
set({ mod: details, loading: false });
|
||||
} catch (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',
|
||||
'/health': 'http://localhost:3000',
|
||||
'/asset': 'http://localhost:3000',
|
||||
'/pdf': 'http://localhost:3000',
|
||||
'/trace': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Scope:** The system's architecture and dependency graph. For implementation
|
||||
> details (files, endpoints, build order), see
|
||||
> [`implementation-plan.md`](./implementation-plan.md). For the rationale behind
|
||||
> key decisions, see [`decisions.md`](./decisions.md).
|
||||
> [`status/implementation-plan.md`](./status/implementation-plan.md). For the
|
||||
> rationale behind key decisions, see [`decisions.md`](./decisions.md).
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -32,6 +32,7 @@ shared types/validation package.
|
||||
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
||||
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | 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 |
|
||||
|
||||
## Dependency graph
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# bgm-commands
|
||||
|
||||
Command execution for [bgm](./format.md) board games, built into
|
||||
[`@tts/tabletop`](./tabletop.md). A command is a unit of scripted
|
||||
interaction — focus the camera, wait for a tap, move a part, show a caption —
|
||||
that runs against the tabletop state store and render layer.
|
||||
|
||||
This doc covers **command execution**: the async lifecycle, run contexts, and
|
||||
tap interaction. The message layer above this — how commands are *declared*
|
||||
and *fired* (triggers, orchestrators, the message queue) — is specified in
|
||||
[`engine.md`](./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
|
||||
|
||||
A command is an async function that returns a result. Every command ends in
|
||||
one of three states, emitted as a message discriminated on the type suffix
|
||||
(see `engine.md` §3):
|
||||
|
||||
- `:done` — completed normally.
|
||||
- `:cancel` — interrupted (a newer command superseded it, the user skipped,
|
||||
the surface was disabled). **Not a failure.**
|
||||
- `:error` — genuinely failed (asset missing, bad path, a thrown exception).
|
||||
|
||||
`cancel` is distinct from `error`: a superseded or skipped command stops
|
||||
cleanly, while a broken command surfaces loudly. The runtime treats them
|
||||
differently — a script that is superseded unwinds without alarming the player,
|
||||
but an `error` is reported.
|
||||
|
||||
```ts
|
||||
type CommandResult<Name extends string, R = void> =
|
||||
| { type: `${Name}:done`; data: R }
|
||||
| { type: `${Name}:cancel` }
|
||||
| { type: `${Name}:error`; error: Error };
|
||||
```
|
||||
|
||||
## 2. run contexts
|
||||
|
||||
Each command invocation creates its own **run context**: the unit of
|
||||
cancellation and the carrier of command-specific state.
|
||||
|
||||
```ts
|
||||
interface CommandRun {
|
||||
id: string;
|
||||
command: Command;
|
||||
status: 'running' | 'ok' | 'cancel' | 'error';
|
||||
data: unknown; // command-specific state, e.g. a pending tap target
|
||||
cancel(): void;
|
||||
done: Promise<CommandResult>;
|
||||
}
|
||||
```
|
||||
|
||||
A command **owns its own state and its own waiting**; the runtime only
|
||||
orchestrates. Its job is to start a run, track its status, cancel it when
|
||||
superseded, and react to its terminal state. This keeps commands
|
||||
self-contained and testable in isolation.
|
||||
|
||||
## 3. fire-and-forget vs self-managed waiting
|
||||
|
||||
Commands fall into two categories:
|
||||
|
||||
- **Fire-and-forget** (`focus`, `highlight`, `caption`) — start and return
|
||||
`ok` immediately (or when their tween settles). The runtime does not block
|
||||
on them.
|
||||
- **Self-managed waiting** (`wait: tap`, a dialog) — the command resolves its
|
||||
own promise when its condition is met. The runtime just awaits it.
|
||||
|
||||
Fire-and-forget commands still get a run context and a cancel path. A `focus`
|
||||
tween superseded by a newer `focus` must be cancellable, or two cameras fight.
|
||||
"Fire-and-forget" means the runtime doesn't await it, not that it has no
|
||||
lifecycle.
|
||||
|
||||
**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`
|
||||
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
|
||||
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Only tap interaction is supported. A tap on a part is detected and reported to
|
||||
the command layer as a `TapEvent`. Parts may declare **trigger points** —
|
||||
named, circular regions the author wants to be tappable.
|
||||
|
||||
```ts
|
||||
interface TriggerPoint {
|
||||
id: string;
|
||||
position: [number, number]; // part-local frame, mm
|
||||
radius: number; // mm
|
||||
}
|
||||
|
||||
interface TapEvent {
|
||||
part: string; // package:type#id
|
||||
position: [number, number]; // part-local frame, mm
|
||||
trigger: TriggerPoint | null; // nearest within radius, or null
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Trigger points are authored in the **part's local frame** (mm, relative to
|
||||
the part's origin), not world space. A part moves, rotates, and flips
|
||||
(facing), so a world-space point would break the moment it moves. The tap
|
||||
point is transformed into the part's local frame at tap time.
|
||||
- Distance is measured in the part's plane. The reported trigger point is the
|
||||
nearest one within its `radius`; ties go to the first declared.
|
||||
- **Every tap on the part is reported**, with the nearest trigger point (or
|
||||
`null` when none is in range). The command decides how to react — resolve,
|
||||
reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the
|
||||
command owns the UX.
|
||||
|
||||
Commands subscribe to the tap stream via the context and unsubscribe on
|
||||
cancel, so a cancelled `wait: tap` never leaks a handler.
|
||||
|
||||
## 5. run context
|
||||
|
||||
The context a command receives is the handle to everything it can affect. The
|
||||
engine defines the base `RunContext` (see `engine.md` §5): `signal`
|
||||
(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop
|
||||
extends it with the handles commands need to mutate the board:
|
||||
|
||||
```ts
|
||||
interface TabletopRunContext extends RunContext {
|
||||
pkg: Package;
|
||||
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
||||
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
||||
// camera, highlight, and overlay handles are added as those subsystems land
|
||||
}
|
||||
```
|
||||
|
||||
## 6. where trigger points come from
|
||||
|
||||
The tap detector reads trigger points from a runtime map keyed by part id; it
|
||||
does not care where they are declared. Declaration (on the part definition, in
|
||||
a setup, or in a script) is the deferred "how to declare" half and lives in
|
||||
`format.md`.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- **Where commands are declared** — the `script` role and its schema
|
||||
(`format.md`), deferred.
|
||||
- **Animation** — a general "ease toward target placement" layer (preferred)
|
||||
vs explicit per-move tweens.
|
||||
- **Camera** — `CameraControls` (drei) vs hand-rolled.
|
||||
- **Triggering** — does a setup reference a script to auto-run, or is a script
|
||||
a separate page the player picks?
|
||||
- **Narration** — pre-recorded audio assets per script, or TTS at runtime?
|
||||
@@ -0,0 +1,263 @@
|
||||
# bgm-engine
|
||||
|
||||
The message layer that drives [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
|
||||
[`commands.md`](./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`](./tabletop.md) is the intended consumer of that contract:
|
||||
it will register the built-in commands (`move`, `focus`, `caption`,
|
||||
`enableSurface`, ...) that mutate the tabletop store and drive the render layer.
|
||||
The engine never imports tabletop, and tabletop is expected to depend on the
|
||||
engine for the message types and the handler registry. **This wiring is designed
|
||||
but not yet landed** — today `@tts/tabletop` has no dependency on the engine;
|
||||
it ships its state store and render layer standalone (see
|
||||
[`../status/bgm-tabletop.md`](../status/bgm-tabletop.md)). 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 `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.
|
||||
@@ -37,9 +37,11 @@ bruce,[]
|
||||
|
||||
### Inline vs file
|
||||
|
||||
`$variants` can be a file/URL path *or* an inline CSV string. If the value
|
||||
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
|
||||
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
|
||||
`$variants` can be a single source or an array of sources. Each source is a
|
||||
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
|
||||
This keeps the two forms self-documenting and applies the same rule to single
|
||||
values and array elements alike. In YAML a block scalar (`|`) is the natural
|
||||
way to write inline CSV; in JSON you'd use `\n`.
|
||||
|
||||
```yaml
|
||||
$variants: |
|
||||
@@ -47,9 +49,21 @@ $variants: |
|
||||
string,string,[number;number;number;number]
|
||||
fish,Fish,[0;0;5;2]
|
||||
grain,Grain,[1;0;5;2]
|
||||
wood,Wood,[2;0;5;2]
|
||||
```
|
||||
|
||||
An array of sources concatenates their rows. This lets one part definition
|
||||
pull from several CSVs with different schemas — e.g. a deck where the regular
|
||||
cards share a face sheet but the jokers have their own:
|
||||
|
||||
```yaml
|
||||
$variants:
|
||||
- ./cards.csv
|
||||
- ./jokers.csv
|
||||
```
|
||||
|
||||
Each source is parsed with its own schema, and its rows extend the original
|
||||
object independently.
|
||||
|
||||
### CSV conventions
|
||||
|
||||
CSV is parsed with `typed-csv`:
|
||||
@@ -70,32 +84,77 @@ declaration, then uses its `include` paths to find the definitions.
|
||||
|
||||
### Code blocks as virtual files
|
||||
|
||||
A code block is a virtual definition file. To give it a name — so `include:`
|
||||
and `$variants` paths can resolve against it — add a `file=` segment to the
|
||||
code block's info string. The name is relative to the current markdown file:
|
||||
A code block is a virtual definition file. Its name is derived from the
|
||||
`role=` on its info string — `role.type.lang` — so it is discoverable by the
|
||||
default `include: ./**/*.yaml` and addressable by that name:
|
||||
|
||||
````md
|
||||
```yaml file=parts/cargo.yaml
|
||||
```yaml role=part.cargo
|
||||
...
|
||||
```
|
||||
|
||||
```csv file=parts/cargo.csv
|
||||
```yaml role=surface.game#main
|
||||
...
|
||||
```
|
||||
|
||||
```yaml role=package
|
||||
...
|
||||
```
|
||||
````
|
||||
|
||||
- A block with `file=` is addressable by that path.
|
||||
- A block without `file=` is auto-named `./${hash}.yaml`, where `hash`
|
||||
is derived from its content. This makes every yaml block naturally
|
||||
discoverable by the default `include: ./**/*.yaml`. Identical blocks dedupe
|
||||
to the same hash.
|
||||
- The `file=` name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||
resolve against. When there is a real file in that path, the codeblock wins.
|
||||
- `file=` implies the file type from its extension; the language tag is
|
||||
optional and only for editor highlighting.
|
||||
- **Hash vs explicit `file=`:** a hashed name is for auto-discovery, not for
|
||||
referencing. To point at a specific yaml block by name, give it an explicit
|
||||
`file=`; otherwise its name is content-derived and unstable.
|
||||
- `role=part.cargo` names the block `part.cargo.yaml`.
|
||||
- `role=surface.game#main` names it `surface.game.yaml`.
|
||||
- `role=package` names it `package.yaml`.
|
||||
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||
resolve against. When there is a real file in that path, the codeblock wins.
|
||||
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
|
||||
names the block `parts/cargo.yaml` regardless of its role.
|
||||
- A block without `role=` is **not** a definition — it is ignored. Discovery
|
||||
is explicit: a block is a definition only when its `role=` (or, for real
|
||||
files, its filename) declares a known `role.type`.
|
||||
|
||||
### role= on the info string
|
||||
|
||||
A block's role is declared on the info string, using the same `role.type#id`
|
||||
shape as the block's identity. `type` and `id` are optional — anything not
|
||||
given comes from the content (or from `$variants` rows):
|
||||
|
||||
````md
|
||||
```yaml role=part.cargo
|
||||
...
|
||||
```
|
||||
|
||||
```yaml role=surface.game#main
|
||||
...
|
||||
```
|
||||
|
||||
```yaml role=package
|
||||
...
|
||||
```
|
||||
````
|
||||
|
||||
- `role=part.cargo` declares a part of type `cargo`; its `id` comes from the
|
||||
content or from `$variants`.
|
||||
- `role=surface.game#main` declares a surface of type `game` with id `main`.
|
||||
- `role=package` declares a package; it has no type.
|
||||
- A `role`/`type`/`id` given on the info string **conflicts** with the same
|
||||
key in the content and errors. `id` on the info string cannot combine with
|
||||
`$variants`, since every row supplies its own `id`.
|
||||
- A block without `role=` is not a definition — discovery is explicit (see
|
||||
above).
|
||||
|
||||
### Real files
|
||||
|
||||
A real `role.type.lang` file (e.g. `part.cargo.yaml`) is a definition by its
|
||||
filename, with no `role=` needed. `role` and `type` are parsed from the name;
|
||||
`id` comes from the content or `$variants`. A real file and a code block with
|
||||
the same name are the same definition; the code block wins.
|
||||
|
||||
### Duplicates
|
||||
|
||||
Two definitions with the same `role.type` are grouped under the same name.
|
||||
They must not define the same `id` — a duplicate `type#id` errors. Blocks with
|
||||
the same `role.type` but different ids are fine.
|
||||
|
||||
### include
|
||||
|
||||
@@ -115,17 +174,24 @@ package can use a `../`-relative pattern or an absolute-from-root pattern
|
||||
|
||||
## 3. Roles
|
||||
|
||||
json objects in yaml blocks are handled if they have a `role:` field for either
|
||||
json objects in yaml blocks are handled if they are declared as a definition
|
||||
by their `role=` (or, for real files, their filename) for either
|
||||
- `package`
|
||||
- `part`
|
||||
- `surface`
|
||||
- `setup`
|
||||
- `dialog`
|
||||
|
||||
a valid object can either be the root or in the list of the yaml block.
|
||||
|
||||
for all roles except package, `type` and `id` are needed.
|
||||
`type#id` is used for identification so that combo must be unique in the package.
|
||||
|
||||
A block declares its role on the info string — `role=part.cargo` is equivalent
|
||||
to `role: part` + `type: cargo` in the content (see §2). A real file declares
|
||||
it in its filename. The info string/filename and content must not both set the
|
||||
same key.
|
||||
|
||||
### package
|
||||
|
||||
The package is the container for a game's definitions. It is declared with a
|
||||
@@ -171,6 +237,10 @@ placed on the board via `setup`, and visualized by routes.
|
||||
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
||||
ratio is kept, but not z (thickness).
|
||||
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
|
||||
- `facing` — the **physical affordance**: the facings the piece supports.
|
||||
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
|
||||
Declares what's *possible*, not what's legal on a given zone (see `surface`
|
||||
`layout`). Defaults to `[face]`.
|
||||
|
||||
#### example
|
||||
|
||||
@@ -268,19 +338,81 @@ surfaces:
|
||||
- board#harbor
|
||||
- hud#hand
|
||||
setup:
|
||||
/dock/0: harbor:boat#fleet
|
||||
/deck: harbor:card
|
||||
- path: /dock/0
|
||||
parts: harbor:boat#fleet
|
||||
- path: /deck
|
||||
parts: harbor:card
|
||||
facing: back
|
||||
- path: /table
|
||||
parts: harbor:token#wood
|
||||
facing: standing
|
||||
```
|
||||
|
||||
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
||||
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
||||
enabled.
|
||||
|
||||
The value on a setup path can be either a string, or a string list.
|
||||
`setup` is an **ordered list of placements**. Each placement moves its `parts`
|
||||
to its `path`, and entries are applied in order — so a part listed in a later
|
||||
placement ends up on that placement's path. This makes a setup read like "deal
|
||||
the deck, then move these cards to the flop".
|
||||
|
||||
The string can either be a one part string, or a type without an id.
|
||||
`parts` can be a single part id, a bare type without an id, or a list of
|
||||
either. A bare type expands to all parts of that type during game state
|
||||
initialization.
|
||||
|
||||
When id is omitted, it expands to all parts in that type during game state initialization.
|
||||
`facing` sets how the placed parts are oriented on the board, defaulting to
|
||||
`face`:
|
||||
|
||||
- `face` — lay flat, front up, resting on the bottom face.
|
||||
- `back` — lay flat, front down (flipped over), resting on the top face.
|
||||
- `standing` — stand upright on the bottom edge, front texture still showing.
|
||||
|
||||
A part's `facing` is seeded into the game state and can change at runtime; it
|
||||
only affects orientation, never the part's texture.
|
||||
|
||||
A setup also declares the **interaction affordances** — which dialogs are the
|
||||
tool for which open interactions on which paths:
|
||||
|
||||
```yaml
|
||||
interactions:
|
||||
- dialog: insert
|
||||
on: [draw, grid] # insertion uses the `insert` dialog on these paths
|
||||
- dialog: shuffle # open the shuffle dialog on any deck
|
||||
```
|
||||
|
||||
`interactions` is a list of declarations: each names a `dialog` (a `role:
|
||||
dialog` definition, see below) and the paths it applies to (`on`, matching by
|
||||
path or by stack). It declares the *interaction surface*, not the legality of
|
||||
the resulting command — rule scripts (later) gate legality. A dialog can be
|
||||
opened by a player gesture or pushed by a rule script; only the trigger differs.
|
||||
|
||||
### dialog
|
||||
|
||||
A `dialog` is declarative content shown in the layer-3 shell. Opening and
|
||||
closing it never issues a command or mutates state; it is pure UI. Its **action
|
||||
buttons** issue commands — the bridge between the dialog and the rule seam.
|
||||
|
||||
```yaml
|
||||
role: dialog
|
||||
type: prompt
|
||||
id: discard
|
||||
title: Choose a card to discard
|
||||
body: |
|
||||
Select a card from your hand.
|
||||
actions:
|
||||
- label: Confirm
|
||||
command: { move: { part: "#chosen", to: /discard } }
|
||||
widget: stack # a stack-of-parts content type
|
||||
```
|
||||
|
||||
A dialog's content is a **title**, **body**, **action buttons**, and an optional
|
||||
`widget`. Supported content types include a **stack of parts** — an ordered,
|
||||
scrolled view of a path's stack with an insertion cursor — which is what powers
|
||||
insertion and shuffling dialogs against the state store.
|
||||
|
||||
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
|
||||
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
|
||||
|
||||
---
|
||||
|
||||
@@ -300,6 +432,11 @@ A surface is enabled or disabled; a disabled surface is not rendered. Setup
|
||||
seeds the enabled set (see §3), and it changes at runtime as the game
|
||||
progresses (e.g. enabling the main board after an expansion-chooser scene).
|
||||
|
||||
Dialogs are **not** part of the state store. The dialog stack is UI state
|
||||
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
|
||||
action buttons issue commands (see `role: dialog` §3), which is the one-way
|
||||
bridge onto the state.
|
||||
|
||||
### Routing
|
||||
|
||||
A route is a **visualization route**: it maps a part to a location on a
|
||||
@@ -315,6 +452,15 @@ A route is an express-style URL path with named params, plus the `x`, `y`, and
|
||||
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
|
||||
same route path may appear more than once:
|
||||
|
||||
A route may also declare a zone `facing` — the **legal facing** on that zone.
|
||||
This constrains what the placed parts may be, independent of each part's
|
||||
physical affordance (see `part` `facing`). A state is legal only when the part's
|
||||
facing is in both the part's affordance *and* the zone's legal set. When omitted,
|
||||
the zone is unrestricted beyond the part's affordance. An MTG discard pile
|
||||
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
|
||||
`[back]`. The same card is face-up in the discard but tapped in play — the
|
||||
piece's affordance is unchanged, only the zone's rule differs.
|
||||
|
||||
```yaml
|
||||
layout:
|
||||
- route: /dock/:seat
|
||||
@@ -0,0 +1,134 @@
|
||||
# bgm Interactions
|
||||
|
||||
The free-interaction layer (layer 3 of the layering) — how a player interacts
|
||||
with a bgm game that has no rules yet: a sandbox. It builds on the state model:
|
||||
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
|
||||
facing, and the anchoring scope.
|
||||
|
||||
> **Status:** Implemented (layer 3, sandbox). The operation set, the
|
||||
> command/dialog split, and the deck-pick-up dialog are built in
|
||||
> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer
|
||||
> 4) is not yet wired — the rule seam is a no-op filter over free interaction.
|
||||
|
||||
## 1. The operation set is closed and tiny
|
||||
|
||||
Two assumptions from the state model do most of the work:
|
||||
|
||||
1. **Parts are never created or destroyed** — the set of parts is fixed (the
|
||||
setup's parts).
|
||||
2. **State is only path, stack, and facing** — there is nowhere to store a free
|
||||
position in space.
|
||||
|
||||
Together they collapse the *entire* space of free interaction into three
|
||||
operations:
|
||||
|
||||
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
|
||||
position (default: top of stack).
|
||||
2. **`setFacing(id, facing)`** — change facing, within the part's physical
|
||||
affordance.
|
||||
3. **reorder** — a `move` with an explicit `index`.
|
||||
|
||||
That's it. There is no arbitrary positioning and no free placement in 3D because
|
||||
the state model has nowhere to store one. "Free" means *unconstrained over these
|
||||
three operations*, not "free in space."
|
||||
|
||||
## 2. Free interaction and rule play are the same operations
|
||||
|
||||
This is the payoff. Rule-enforced play (layer 4) is **the same three
|
||||
operations, gated by a legality check**:
|
||||
|
||||
- The interaction layer produces an **intent** — "player wants
|
||||
`move(card, /discard)`."
|
||||
- **Sandbox mode**: apply it directly.
|
||||
- **Rule mode**: validate the intent against the setup's shape + the rule script
|
||||
first; reject if illegal.
|
||||
|
||||
So the rule engine needs no interaction vocabulary of its own — it is a **filter
|
||||
over free interaction**. `move`/`setFacing` are the shared primitives; rules
|
||||
decide which are legal in the current state. Drag-and-drop and a scripted move
|
||||
both funnel through the same store mutation. That is the seam between layer 3
|
||||
and layer 4.
|
||||
|
||||
## 3. Commands vs the dialog stack
|
||||
|
||||
There are two channels, and they do not mix:
|
||||
|
||||
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
|
||||
the rule seam and mutate the store.
|
||||
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
|
||||
discard," a hint prompt). Opening/closing a dialog **never issues a command**
|
||||
and never touches state; it is pure UI.
|
||||
|
||||
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
|
||||
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
|
||||
|
||||
How they connect — one direction only:
|
||||
|
||||
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
|
||||
insert button issues a `move` command.
|
||||
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
|
||||
discard) and awaits the player's `move` through it. The script's open/close
|
||||
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
|
||||
game state.
|
||||
|
||||
So the deck dialog is **one implementation, driven either way** — by a player
|
||||
click or by a rule script. Only the *trigger* differs.
|
||||
|
||||
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
|
||||
definition declares the title, body, action buttons, and an optional widget
|
||||
(a stack of parts). Setups declare which dialogs are the tool for which
|
||||
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
|
||||
rule seam stays clean because a dialog's buttons issue commands while its
|
||||
open/close is stack-only.
|
||||
|
||||
## 4. Compound interactions: the deck pick-up dialog
|
||||
|
||||
A dialog is where compound, multi-step manipulation lives, because it owns the
|
||||
transient sub-state that the store must not. The prime example — inserting a
|
||||
card into the middle of a deck:
|
||||
|
||||
1. pick up the deck
|
||||
2. scroll through it to find the place
|
||||
3. insert the card at the cursor
|
||||
4. put the deck back
|
||||
|
||||
(even 0: put down your held hand of cards first).
|
||||
|
||||
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
|
||||
table into the dialog (it stays on its path; the rest of the table becomes
|
||||
backdrop). Its contents are shown **in order**, with an **insertion cursor**
|
||||
between cards that you scroll. The cursor *is* the index: `move(id, path,
|
||||
index)`'s index is discovered by scrolling the visible deck, not typed.
|
||||
|
||||
This is a real interaction *mode*, owned by the dialog:
|
||||
|
||||
- The scroll position is state.
|
||||
- Everything else pauses while it's open (or the held part is kept visible).
|
||||
- It only exists for **stacks**; single parts just snap onto a path.
|
||||
|
||||
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
|
||||
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
|
||||
same lifted-deck view.
|
||||
|
||||
## 5. The primitives map to concrete gestures
|
||||
|
||||
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
|
||||
resolve the nearest path anchor within a threshold; drop → `move` (drop on
|
||||
nothing returns to origin).
|
||||
- **Facing** — click cycles the part through its physical affordance
|
||||
(`face → back → standing`, or the declared list).
|
||||
- **Reorder / insert** — the deck dialog above.
|
||||
|
||||
The "in hand" state is transient and illegal, so it lives **outside the store**
|
||||
(a UI-level held part); only the committed outcome (drop) mutates the store.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Held source for insertion** — single held card (fits the physical model; the
|
||||
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
|
||||
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
|
||||
fight the hand step.
|
||||
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
|
||||
single-part primitives are the foundation.
|
||||
- **Scroll window** — a window over a subset of the deck can return with the
|
||||
cursor's scroll position; exact widget is a render concern, not a state one.
|
||||
@@ -0,0 +1,137 @@
|
||||
# bgm State Model
|
||||
|
||||
The format's model of what a board game *is*, and the shape of the states it can
|
||||
be in. This is the organizing model behind the concrete specs:
|
||||
[`format.md`](./format.md) (the manifest), [`tabletop.md`](./tabletop.md) (the
|
||||
render library), and [`engine.md`](./engine.md) / [`commands.md`](./commands.md)
|
||||
(scripted interaction).
|
||||
|
||||
> **Status:** Design. The loader and render layer implement components and a
|
||||
> static setup today; the state-shape framing, zone-facing, and the anchoring
|
||||
> scope cut below are proposals to guide the next phase.
|
||||
|
||||
## 1. Components are constants; setup is the shape of states
|
||||
|
||||
A bgm game is authored two ways, and they answer different questions:
|
||||
|
||||
- **Components** (`part`, `surface`) describe the **constants** — the physical
|
||||
pieces and the board geometry that do not change during play. A tile is a
|
||||
45×45 mm square with a meadow/road/city face; the play board has a draw pile
|
||||
and an 11×11 grid. These are timeless facts about the game, not state.
|
||||
- **setup** describes the **shape of the game's states** — which parts can be
|
||||
where, how many, and with what facing. It is a *schema* over the state space,
|
||||
not a concrete snapshot of one run.
|
||||
|
||||
The runtime store is then an **instance** of the setup's shape: the current
|
||||
state of play, a point in the state space the setup describes. This buys three
|
||||
things:
|
||||
|
||||
- **Validation** — a state is legal iff it matches the setup's shape (on the
|
||||
right paths, right counts, right facing).
|
||||
- **A contract for the rule engine** — legal play is a *transition between
|
||||
shapes*; the rule layer (layer 4 in the layering vision) reasons over them.
|
||||
- **A clean boundary** — components are timeless; setup is the state space; the
|
||||
store is the current point.
|
||||
|
||||
### Open: does setup carry the initial state?
|
||||
|
||||
A game needs a concrete starting position, not just a schema. The default is
|
||||
that a `setup` is **shape + initial instance** — one role that both describes
|
||||
the legal state space and seeds the store. The alternative (schema-only, with
|
||||
the initial state derived from the shape) is explored but not preferred.
|
||||
|
||||
## 2. The state: path → stack × facing
|
||||
|
||||
The state space is made of two axes.
|
||||
|
||||
### Path → stack
|
||||
|
||||
Every part lives on a **path** (a URL-style key like `/grid/5/5` or `/draw`),
|
||||
and multiple parts on a path form an ordered **stack** (a deck, a pile of meeples,
|
||||
a tile with a meeple on it). Placement is one axis. This is already in use.
|
||||
|
||||
### facing — part affordance × zone restriction
|
||||
|
||||
Facing is the second axis, and it has two distinct sources of constraint:
|
||||
|
||||
- **The part** declares the **physical affordance** — the facings the piece
|
||||
physically supports. A card supports face/back/standing; a tile supports
|
||||
face/back but not "tapped".
|
||||
- **The path (zone)** declares the **legal facing** — what's allowed on that
|
||||
zone. An MTG discard pile requires face-up; a play area allows tapped; a
|
||||
facedown deck requires face-down. The same card is face-up in the discard,
|
||||
tapped in play, face-up in exile — the piece's affordance doesn't change, only
|
||||
the zone's rule does.
|
||||
|
||||
A state's facing is legal iff it is **both physically possible (part) and
|
||||
zone-legal (path)** — the effective set is the intersection.
|
||||
|
||||
```yaml
|
||||
role: part
|
||||
type: card
|
||||
id: basic
|
||||
facing: [face, back, standing] # physical affordance
|
||||
|
||||
role: surface
|
||||
type: board
|
||||
id: main
|
||||
layout:
|
||||
- route: /discard
|
||||
facing: [face] # zone restriction
|
||||
- route: /play
|
||||
facing: [face, tap] # zone restriction
|
||||
```
|
||||
|
||||
Deck / the current facing lives in the store and must be in the intersection.
|
||||
|
||||
### Open questions
|
||||
|
||||
- **Default when a path declares no `facing`** — unrestricted (only the part's
|
||||
affordance bounds it), or a sensible default like `face`? Lean unrestricted.
|
||||
- **Is "tapped" a facing or a rotation?** In MTG it's a 90° in-plane rotation.
|
||||
Default: fold common rotations into the facing enum (`face` / `back` /
|
||||
`standing` / `tap`) for schema simplicity; arbitrary rotation is a later
|
||||
extension.
|
||||
- **Naming** — the part-side and path-side are different constraints wearing the
|
||||
same word. Worth distinct terms (capability/typ) so they don't collide.
|
||||
|
||||
## 3. Anchoring scope: stacks-on-paths, not part-to-part networks
|
||||
|
||||
Real TTS mods anchor components to each other — a meeple on a tile, a fanned
|
||||
hand, tokens scattered on a board. We are **not** modeling part-aligned-to-part
|
||||
relative placement networks. Instead:
|
||||
|
||||
- **Stacks absorb part-on-part.** "Meeple on tile" is a stack `[tile, meeple]`
|
||||
on a path. Much of TTS's anchoring collapses into the path→stack model.
|
||||
- **Free relative placement is out of scope** — a meeple at an offset on a tile,
|
||||
a fanned hand, arbitrary token scatter are not modeled.
|
||||
|
||||
This is a deliberate scope cut. It keeps the state model closed and simple, but
|
||||
it is a **fidelity loss for converted games** and part of the "with some fixing"
|
||||
cost of the TTS→bgm conversion. Components (layer 1), not the state model, are
|
||||
the place to extend later if needed.
|
||||
|
||||
### The grid compromise
|
||||
|
||||
Because we don't model parts-aligned-to-parts, common layouts are expressed
|
||||
explicitly — and grid layouts become verbose (the Carcassonne board is 121
|
||||
hand-written row coordinates in `grid.csv`). The mitigations:
|
||||
|
||||
- **Grid shorthand** — a declarative `grid` (cols/rows, cell size, origin) that
|
||||
expands to routes, instead of authored coordinates.
|
||||
- **Free-placement shorthand** — a `free`/scatter mode for loose collections,
|
||||
when the exact positions don't matter to play.
|
||||
|
||||
But there is **no general part-to-part network** on the roadmap. If a converted
|
||||
game needs it, that is a format extension to design deliberately, not an
|
||||
implicit assumption.
|
||||
|
||||
## Summary
|
||||
|
||||
| Concept | Role | Where |
|
||||
| --- | --- | --- |
|
||||
| Components | Constants — the physical pieces & board geometry | `part`, `surface` |
|
||||
| Setup | The shape of the state space (+ initial instance) | `setup` |
|
||||
| Store | The current state, an instance of the setup | `@tts/tabletop` |
|
||||
| Facing | Part affordance × zone restriction | part + path/zone restriction |
|
||||
| Anchoring | Stacks-on-paths only; no part networks | — |
|
||||
@@ -1,10 +1,15 @@
|
||||
# bgm-tabletop
|
||||
|
||||
a r3f based interactive component library to work with [bgm](./bgm-format.md) board games. will be used somewhere in the `web` app's bgm inspector routes.
|
||||
> **Status:** Implemented (items 1–8 of the plan). A few features below are
|
||||
> designed but not yet wired (commands, HUD rendering); see §5 and
|
||||
> [`../status/bgm-tabletop.md`](../status/bgm-tabletop.md).
|
||||
|
||||
An r3f-based interactive component library for working with [bgm](./format.md)
|
||||
board games. It is used in the `web` app's bgm inspector routes.
|
||||
|
||||
## 1. stack
|
||||
|
||||
`react` - react, react router, tailwindv4
|
||||
`react` - react, react router, tailwind v4
|
||||
`r3f` - r3f, drei, postprocessing
|
||||
`zustand` - for state management
|
||||
|
||||
@@ -15,18 +20,25 @@ source-of-truth game state:
|
||||
```ts
|
||||
{
|
||||
surfaces: Record<string, boolean>, // enabled per surface id
|
||||
paths: Record<string, string[]>, // path -> part list
|
||||
parts: Record<string, PartState>, // part id -> placement state
|
||||
}
|
||||
|
||||
interface PartState {
|
||||
path: string, // the path key this part is on
|
||||
index: number, // the part's position in its path's stack
|
||||
facing: 'face' | 'back' | 'standing', // how the part is oriented on the board
|
||||
}
|
||||
```
|
||||
|
||||
**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous.
|
||||
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
|
||||
|
||||
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list.
|
||||
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, facing }` for rendering on a surface. keys of this map makes a stable render list.
|
||||
|
||||
- `route` - the matched route.
|
||||
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
||||
- `index` - the piece's position in its path's stack.
|
||||
- `stackSize` - the number of pieces on the path.
|
||||
- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`).
|
||||
|
||||
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
||||
|
||||
@@ -40,9 +52,17 @@ the render map is per enabled surface: a piece may appear on more than one enabl
|
||||
|
||||
## 4. stacking
|
||||
|
||||
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||
|
||||
## 5. usage
|
||||
## 5. commands
|
||||
|
||||
Scripted interaction — focus, tap-to-advance, move, caption — is built on an
|
||||
async command layer. See [`commands.md`](./commands.md) for command
|
||||
execution (lifecycle, run contexts, tap interaction), and
|
||||
[`engine.md`](./engine.md) for the message layer above it (the queue,
|
||||
triggers, and orchestrators that declare and fire commands).
|
||||
|
||||
## 6. usage
|
||||
|
||||
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
||||
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
||||
+66
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Scope:** The rationale behind key design decisions. For the system's
|
||||
> architecture, see [`architecture.md`](./architecture.md). For the concrete
|
||||
> build plan, see [`implementation-plan.md`](./implementation-plan.md).
|
||||
> build plan, see [`status/implementation-plan.md`](./status/implementation-plan.md).
|
||||
>
|
||||
> Each entry records the decision, the context, and the alternatives considered.
|
||||
> New entries are appended; existing entries are updated only to correct facts,
|
||||
@@ -259,4 +259,68 @@ boundary.
|
||||
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
|
||||
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
||||
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
||||
geometry directly.
|
||||
geometry directly.
|
||||
|
||||
## D19 — Commands are async with ok/cancel/error results
|
||||
|
||||
**Decision:** Scripted interaction is built on async commands. Each command
|
||||
returns `ok`, `cancel` (interrupted — superseded, skipped, surface disabled),
|
||||
or `error` (genuinely failed). Each invocation gets its own run context — the
|
||||
unit of cancellation and the carrier of command state. Commands are either
|
||||
fire-and-forget (the runtime doesn't await them) or self-managed waiting (they
|
||||
resolve their own promise when a condition is met); both get a run context and
|
||||
cancel path. Supersede groups cancel a running command when another in the
|
||||
group starts (e.g. a `camera` group so a second focus cancels the first).
|
||||
|
||||
**Context:** The user wants to script interaction sequences — focus, caption,
|
||||
title, highlight, tap-to-advance, move, camera away. The state store and
|
||||
render layer already exist; what's missing is a way to drive them over time
|
||||
and react to input. Design: [`bgm/commands.md`](./bgm/commands.md).
|
||||
|
||||
**Alternatives considered:** A single monolithic script interpreter. Rejected
|
||||
— commands as self-contained async units are testable in isolation and let
|
||||
the runtime stay a thin orchestrator.
|
||||
|
||||
## D20 — Tap interaction reports every tap with the nearest trigger point
|
||||
|
||||
**Decision:** Only tap interaction is supported. A tap on a part is reported
|
||||
to the command layer as a `TapEvent` carrying the part, the tap position in
|
||||
the part's local frame, and the nearest trigger point within its `radius` (or
|
||||
`null` on a miss). Trigger points are authored in the part's local frame with
|
||||
mm radius; distance is measured in the part's plane; ties go to the first
|
||||
declared. The command decides how to react to a miss — resolve, reject, or
|
||||
ignore.
|
||||
|
||||
**Context:** Commands need to wait on player input (`wait: tap`). Reporting
|
||||
every tap with the nearest trigger point keeps the runtime dumb and lets the
|
||||
command own the UX (e.g. a "wrong spot" shake). Authoring trigger points in
|
||||
the part's local frame keeps them valid as the part moves, rotates, and
|
||||
flips.
|
||||
|
||||
**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
|
||||
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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Docs
|
||||
|
||||
The documentation is split into the living specs, which describe how the
|
||||
system works today, and the status/plans, which track development iterations
|
||||
and go stale as work lands.
|
||||
|
||||
## Specs — how the system works
|
||||
|
||||
| Doc | Covers |
|
||||
| --- | --- |
|
||||
| [`architecture.md`](./architecture.md) | System architecture and the package dependency graph |
|
||||
| [`decisions.md`](./decisions.md) | Key design decisions and the rationale behind them |
|
||||
| [`bgm/format.md`](./bgm/format.md) | The board game manifest (bgm) format spec |
|
||||
| [`bgm/engine.md`](./bgm/engine.md) | The bgm message layer: queue, triggers, orchestrators |
|
||||
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
|
||||
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
|
||||
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
|
||||
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
|
||||
|
||||
## Status & plans (dev logs)
|
||||
|
||||
| Doc | Covers |
|
||||
| --- | --- |
|
||||
| [`status/implementation-plan.md`](./status/implementation-plan.md) | Original build plan |
|
||||
| [`status/bgm-loader.md`](./status/bgm-loader.md) | bgm loader — what's built, works, missing |
|
||||
| [`status/bgm-tabletop.md`](./status/bgm-tabletop.md) | bgm tabletop — implementation plan / status |
|
||||
| [`status/full-setup-view.md`](./status/full-setup-view.md) | Full-setup view plan |
|
||||
@@ -1,7 +1,7 @@
|
||||
# bgm Loader — Status
|
||||
|
||||
> WIP. What's built, what works, what's missing, and the known issues.
|
||||
> Spec: [`bgm-format.md`](./bgm-format.md).
|
||||
> Status: current. What's built, what works, what's missing, and the known
|
||||
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
|
||||
|
||||
## What's built
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
||||
|
||||
### `games/harbor/harbor.md` — example game (new)
|
||||
### Example game: harbor (fixture)
|
||||
|
||||
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
|
||||
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Lives as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/` (and a copy under `packages/tabletop/src/__fixtures__/vite-build/games/harbor/`).
|
||||
|
||||
### `games/poker/poker.md` — example game (new)
|
||||
|
||||
@@ -30,7 +30,7 @@ A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards,
|
||||
|
||||
### `packages/tabletop` — rendering library (new)
|
||||
|
||||
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop-plan.md`](./bgm-tabletop-plan.md).
|
||||
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
||||
|
||||
### `packages/http` — shared proxy HTTP (new)
|
||||
|
||||
@@ -71,5 +71,5 @@ The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/
|
||||
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
|
||||
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
|
||||
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
|
||||
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `docs/bgm-tabletop.md`).
|
||||
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `../bgm/tabletop.md`).
|
||||
- Docs for the loader itself (this file is the start).
|
||||
@@ -1,8 +1,9 @@
|
||||
# bgm-tabletop — Implementation Plan / Status
|
||||
|
||||
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
||||
> board games: a state store, surface mounting, part placement with stacking,
|
||||
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
||||
> **Scope:** A standalone r3f component library that renders
|
||||
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
|
||||
> placement with stacking, and per-part meshes. Design:
|
||||
> [`../bgm/tabletop.md`](../bgm/tabletop.md).
|
||||
> **Status:** items 1–8 implemented and the full tabletop scene is wired into
|
||||
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
|
||||
> part-inspection route renders `PartView` from the library.
|
||||
@@ -75,29 +76,36 @@ useful slice and unblocks the web app's part inspection route immediately.
|
||||
|
||||
### 3. State store (`state.ts`) ✅
|
||||
|
||||
Source-of-truth game state per `bgm-tabletop.md` §2:
|
||||
Source-of-truth game state per `../bgm/tabletop.md` §2:
|
||||
|
||||
```ts
|
||||
interface GameState {
|
||||
surfaces: Record<string, boolean>; // enabled per surface id
|
||||
paths: Record<string, string[]>; // path -> part list
|
||||
parts: Record<string, PartState>; // part id -> placement state
|
||||
}
|
||||
|
||||
interface PartState {
|
||||
path: string; // the path key this part is on
|
||||
index: number; // the part's position in its path's stack
|
||||
facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board
|
||||
}
|
||||
```
|
||||
|
||||
- A zustand store holding `GameState`.
|
||||
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||
`{ surface, route, candidate, index, stackSize }``, per enabled surface.
|
||||
Computed with a selector/memo so the render list is stable.
|
||||
- **Assumption**: each piece id is unique within a path (documented in
|
||||
`bgm-tabletop.md`); the render map is keyed by piece id.
|
||||
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||
- **Assumption**: each piece id is unique on the board (documented in
|
||||
`../bgm/tabletop.md`); the render map is keyed by piece id.
|
||||
|
||||
### 4. Setup seeding (`setup.ts`) ✅
|
||||
|
||||
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
|
||||
`setup` paths.
|
||||
`Setup` — enables its `surfaces` (or all when omitted) and applies its
|
||||
ordered `setup` placements (each moves its `parts` to a `path`).
|
||||
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
||||
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
|
||||
type (documented in `../bgm/format.md` §3; the loader doesn't do this — it's a
|
||||
game-state init concern, so it lives here).
|
||||
|
||||
### 5. Surface mounting (`mount.ts`) ✅
|
||||
@@ -119,7 +127,7 @@ interface GameState {
|
||||
### 7. Stacking (`stacking.ts`) ✅
|
||||
|
||||
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
|
||||
- Implements the format's positioning process (`bgm-format.md` §4): step
|
||||
- Implements the format's positioning process (`../bgm/format.md` §4): step
|
||||
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
||||
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
|
||||
@@ -165,6 +173,36 @@ consumers share them (see Open decisions).
|
||||
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||
from the library (work item 2), proving it end-to-end.
|
||||
|
||||
## Free interaction (layer 3) ✅
|
||||
|
||||
Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md):
|
||||
|
||||
- `interactions.ts` — the held part + dialog stack (UI state, outside the game
|
||||
store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`,
|
||||
`partFacings`, `nextFacing`).
|
||||
- `state.ts` — `setFacing` alongside `movePart` (the `move(id, path, index?)`
|
||||
primitive, defaulting to top of stack).
|
||||
- `placement.tsx` — `PartPlacement` is now interactive: pick up a part (held,
|
||||
lifted above the board), drag to a path anchor to `move`, click to cycle
|
||||
facing. A drop on a path with a stack-dialog interaction opens the deck
|
||||
dialog instead of moving directly.
|
||||
- `dialog.tsx` — `DialogLayer`, the stack-inspector dialog: an alternate view
|
||||
of a stack with an insertion cursor; its insert button issues a `move`.
|
||||
- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs`
|
||||
(schema + collection + serialization).
|
||||
- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack
|
||||
insert) on the deck.
|
||||
|
||||
The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
|
||||
|
||||
## Commands (not yet implemented)
|
||||
|
||||
Scripted interaction is designed in [`../bgm/commands.md`](../bgm/commands.md):
|
||||
async commands with `ok`/`cancel`/`error` results, per-invocation run
|
||||
contexts, fire-and-forget vs self-managed waiting, and tap interaction with
|
||||
part-local trigger points. Implementation order: types + run-context manager,
|
||||
tap detection, then the first commands (`wait: tap`, `focus`).
|
||||
|
||||
## Open decisions (defaults in bold)
|
||||
|
||||
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
||||
@@ -81,9 +81,10 @@ view and the full-setup view.
|
||||
`textureUrl + color + roughness`. drei already caches textures by URL
|
||||
globally, so sharing the material on top avoids per-object material
|
||||
allocation for tiles/tokens with the same image.
|
||||
- **Cards are the exception:** each card clones its texture for sprite UVs, so
|
||||
its face material cannot be shared — but its geometry still can (same card
|
||||
size).
|
||||
- **Cards:** the face/back textures are shared (drei caches them by URL) and
|
||||
the sprite cell is selected via a per-material UV transform injected into the
|
||||
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
|
||||
the session (see Open decisions).
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
||||
> order. For the system's architecture and dependency graph, see
|
||||
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
|
||||
> see [`decisions.md`](./decisions.md).
|
||||
> [`../architecture.md`](../architecture.md). For the rationale behind key decisions,
|
||||
> see [`../decisions.md`](../decisions.md).
|
||||
|
||||
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
||||
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
||||
@@ -55,7 +55,16 @@ tts-workshop/
|
||||
├── .npmrc
|
||||
├── .env.example # STEAM_API_KEY, PORT
|
||||
├── docs/
|
||||
│ └── implementation-plan.md # this file
|
||||
│ ├── overview.md
|
||||
│ ├── architecture.md
|
||||
│ ├── decisions.md
|
||||
│ ├── bgm/
|
||||
│ │ ├── format.md
|
||||
│ │ ├── engine.md
|
||||
│ │ ├── commands.md
|
||||
│ │ └── tabletop.md
|
||||
│ └── status/
|
||||
│ └── implementation-plan.md # this file
|
||||
├── apps/
|
||||
│ ├── proxy/
|
||||
│ │ ├── package.json
|
||||
@@ -4,8 +4,7 @@ The base game's 24 landscape tiles, laid out on a table with a draw pile and a
|
||||
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
||||
standard `45×45` mm square.
|
||||
|
||||
```yaml file=carcassonne.yaml
|
||||
role: package
|
||||
```yaml role=package
|
||||
id: carcassonne
|
||||
title: Carcassonne
|
||||
designer: Klaus-Jürgen Wrede
|
||||
@@ -21,9 +20,7 @@ One `tile` part per distinct tile type (A–X). All tiles share the same square
|
||||
face image and a uniform size; the `$variants` CSV expands them into the 24
|
||||
tile parts.
|
||||
|
||||
```yaml file=tiles.yaml
|
||||
role: part
|
||||
type: tile
|
||||
```yaml role=part.tile
|
||||
face: ./20AE_Base_Game_C2_Tile_A.png
|
||||
size: [45, 45, 3]
|
||||
fillet: 1
|
||||
@@ -65,10 +62,8 @@ A table with a draw pile on the left and an `11×11` grid of placed tiles in the
|
||||
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
||||
so tiles sit edge to edge.
|
||||
|
||||
```yaml file=board.yaml
|
||||
type: board
|
||||
```yaml role=surface.board
|
||||
id: board
|
||||
role: surface
|
||||
size: [600, 600]
|
||||
layout:
|
||||
- route: /draw
|
||||
@@ -215,15 +210,17 @@ string,string,number,number,number
|
||||
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
|
||||
every tile of that type), then seed the grid with a few opening tiles.
|
||||
|
||||
```yaml file=main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
```yaml role=setup.game
|
||||
id: main
|
||||
surfaces:
|
||||
- board#board
|
||||
setup:
|
||||
/draw: carcassonne:tile
|
||||
/grid/5/5: carcassonne:tile#a
|
||||
/grid/5/6: carcassonne:tile#b
|
||||
/grid/6/5: carcassonne:tile#c
|
||||
- path: /draw
|
||||
parts: carcassonne:tile
|
||||
- path: /grid/5/5
|
||||
parts: carcassonne:tile#a
|
||||
- path: /grid/5/6
|
||||
parts: carcassonne:tile#b
|
||||
- path: /grid/6/5
|
||||
parts: carcassonne:tile#c
|
||||
```
|
||||
+39
-17
@@ -4,8 +4,7 @@ A standard 52-card poker deck, laid out on a table with a draw pile and
|
||||
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
||||
generate a full deck from a single part definition.
|
||||
|
||||
```yaml file=poker.yaml
|
||||
role: package
|
||||
```yaml role=package
|
||||
id: poker
|
||||
title: Poker
|
||||
designer: Public Domain
|
||||
@@ -21,9 +20,7 @@ picks a cell from the `13×4` face sheet (`cards-13x4.jpg`) — 13 ranks across,
|
||||
4 suits down. All cards share the same back from the `4×1` back sheet
|
||||
(`back-4x1.png`).
|
||||
|
||||
```yaml file=cards.yaml
|
||||
role: part
|
||||
type: card
|
||||
```yaml role=part.card
|
||||
face: ./cards-13x4.jpg
|
||||
back: ./back-4x1.png
|
||||
size: [63, 88, 0.3]
|
||||
@@ -93,10 +90,8 @@ ac,A,clubs,[12;3;13;4],[0;0;4;1]
|
||||
A table with a draw pile on the left and five community-card slots across the
|
||||
middle. The deck pile fans its stacked cards along a curve.
|
||||
|
||||
```yaml file=board.yaml
|
||||
type: board
|
||||
```yaml role=surface.board
|
||||
id: poker
|
||||
role: surface
|
||||
size: [600, 400]
|
||||
layout:
|
||||
- route: /deck
|
||||
@@ -125,16 +120,43 @@ string,number,number,number
|
||||
|
||||
## Setup
|
||||
|
||||
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
|
||||
that type), then flip a flop onto the community slots.
|
||||
Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
|
||||
card of that type, `facing: back`), then flip a flop onto the community slots
|
||||
and stand a couple of drawn cards on their bottom edge.
|
||||
|
||||
```yaml file=main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
```yaml role=setup.game
|
||||
id: main
|
||||
setup:
|
||||
/deck: poker:card
|
||||
/community/0: poker:card#as
|
||||
/community/1: poker:card#kh
|
||||
/community/2: poker:card#7d
|
||||
- path: /deck
|
||||
parts: poker:card
|
||||
facing: back
|
||||
- path: /community/0
|
||||
parts: poker:card#as
|
||||
- path: /community/1
|
||||
parts: poker:card#kh
|
||||
- path: /community/2
|
||||
parts: poker:card#7d
|
||||
- path: /community/3
|
||||
parts: poker:card#jc
|
||||
facing: standing
|
||||
- path: /community/4
|
||||
parts: poker:card#2h
|
||||
facing: standing
|
||||
interactions:
|
||||
- dialog: prompt#insert
|
||||
on: [/deck]
|
||||
```
|
||||
|
||||
## Dialog
|
||||
|
||||
Inserting a card into the middle of the deck is a compound interaction: the
|
||||
deck is lifted into a stack-inspector dialog with an insertion cursor. The
|
||||
cursor *is* the index — `move(id, /deck, index)` is discovered by scrolling,
|
||||
not typed.
|
||||
|
||||
```yaml role=dialog.prompt
|
||||
id: insert
|
||||
title: Insert into deck
|
||||
body: Scroll to the insertion point, then insert the held card.
|
||||
widget: stack
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
|
||||
A tiny example game used to exercise the bgm loader.
|
||||
|
||||
```yaml file=harbor.yaml
|
||||
role: package
|
||||
```yaml role=package
|
||||
id: harbor
|
||||
title: Harbor
|
||||
designer: Jane Doe
|
||||
players: 2
|
||||
language: en
|
||||
include: ['./**/*.yaml']
|
||||
```
|
||||
|
||||
## Tokens
|
||||
|
||||
```yaml file=parts/tokens.yaml
|
||||
role: part
|
||||
type: token
|
||||
```yaml role=part.token
|
||||
id: wood
|
||||
face: ./assets/tokens.png
|
||||
faceCrop: [1, 0, 5, 2]
|
||||
@@ -26,9 +24,7 @@ size: [20, 20, 3]
|
||||
fillet: 2
|
||||
```
|
||||
|
||||
```yaml file=parts/tokens.yaml
|
||||
role: part
|
||||
type: token
|
||||
```yaml role=part.token
|
||||
id: grain
|
||||
face: ./assets/tokens.png
|
||||
faceCrop: [0, 0, 5, 2]
|
||||
@@ -41,10 +37,8 @@ fillet: 2
|
||||
|
||||
## Board
|
||||
|
||||
```yaml file=parts/board.yaml
|
||||
type: board
|
||||
```yaml role=surface.board
|
||||
id: harbor
|
||||
role: surface
|
||||
size: [300, 200]
|
||||
mount:
|
||||
kind: table
|
||||
@@ -63,10 +57,8 @@ layout:
|
||||
rotation: 0
|
||||
```
|
||||
|
||||
```yaml file=parts/player.yaml
|
||||
type: board
|
||||
```yaml role=surface.board
|
||||
id: player
|
||||
role: surface
|
||||
size: [200, 200]
|
||||
mount:
|
||||
kind: child
|
||||
@@ -79,14 +71,14 @@ layout:
|
||||
$variants: ./hand.csv
|
||||
```
|
||||
|
||||
```csv file=parts/hand.csv
|
||||
```csv file=hand.csv
|
||||
slot,x,y,rotation
|
||||
string,number,number,number
|
||||
0,0,0,0
|
||||
1,0,20,0
|
||||
```
|
||||
|
||||
```csv file=parts/seats.csv
|
||||
```csv file=seats.csv
|
||||
seat,x,y,rotation
|
||||
string,number,number,number
|
||||
0,40,0,0
|
||||
@@ -95,14 +87,14 @@ string,number,number,number
|
||||
|
||||
## Setup
|
||||
|
||||
```yaml file=setup/main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
```yaml role=setup.game
|
||||
id: main
|
||||
surfaces:
|
||||
- board#harbor
|
||||
- board#player
|
||||
setup:
|
||||
/dock/0: harbor:token#wood
|
||||
/deck: harbor:token#grain
|
||||
- path: /dock/0
|
||||
parts: harbor:token#wood
|
||||
- path: /deck
|
||||
parts: harbor:token#grain
|
||||
```
|
||||
@@ -3,8 +3,7 @@
|
||||
A second tiny example game, used to exercise the loader's collection of
|
||||
multiple packages.
|
||||
|
||||
```yaml file=azul.yaml
|
||||
role: package
|
||||
```yaml role=package
|
||||
id: azul
|
||||
title: Azul
|
||||
designer: Michael Kiesling
|
||||
@@ -13,9 +12,7 @@ language: en
|
||||
include: ['./**/*.yaml']
|
||||
```
|
||||
|
||||
```yaml file=parts/tiles.yaml
|
||||
role: part
|
||||
type: tile
|
||||
```yaml role=part.tile
|
||||
id: blue
|
||||
face: ./assets/tiles.png
|
||||
faceCrop: [0, 0, 5, 5]
|
||||
@@ -23,10 +20,8 @@ size: [20, 20, 3]
|
||||
fillet: 1
|
||||
```
|
||||
|
||||
```yaml file=parts/board.yaml
|
||||
type: board
|
||||
```yaml role=surface.board
|
||||
id: azul
|
||||
role: surface
|
||||
size: [400, 300]
|
||||
layout:
|
||||
- route: /factory/:n
|
||||
@@ -34,7 +29,7 @@ layout:
|
||||
$variants: ./factories.csv
|
||||
```
|
||||
|
||||
```csv file=parts/factories.csv
|
||||
```csv file=factories.csv
|
||||
n,x,y,rotation
|
||||
string,number,number,number
|
||||
0,-150,0,0
|
||||
@@ -43,10 +38,9 @@ string,number,number,number
|
||||
3,150,0,0
|
||||
```
|
||||
|
||||
```yaml file=setup/main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
```yaml role=setup.game
|
||||
id: main
|
||||
setup:
|
||||
/factory/0: azul:tile#blue
|
||||
- path: /factory/0
|
||||
parts: azul:tile#blue
|
||||
```
|
||||
@@ -3,8 +3,7 @@
|
||||
A tiny example game used to exercise the bgm loader end-to-end through a real
|
||||
vite build.
|
||||
|
||||
```yaml file=harbor.yaml
|
||||
role: package
|
||||
```yaml role=package
|
||||
id: harbor
|
||||
title: Harbor
|
||||
designer: Jane Doe
|
||||
@@ -13,9 +12,7 @@ language: en
|
||||
include: ['./**/*.yaml']
|
||||
```
|
||||
|
||||
```yaml file=parts/tokens.yaml
|
||||
role: part
|
||||
type: token
|
||||
```yaml role=part.token
|
||||
id: wood
|
||||
face: ./assets/tokens.png
|
||||
faceCrop: [1, 0, 5, 2]
|
||||
@@ -26,10 +23,8 @@ size: [20, 20, 3]
|
||||
fillet: 2
|
||||
```
|
||||
|
||||
```yaml file=parts/board.yaml
|
||||
type: board
|
||||
```yaml role=surface.board
|
||||
id: harbor
|
||||
role: surface
|
||||
size: [300, 200]
|
||||
layout:
|
||||
- route: /dock/:seat
|
||||
@@ -41,18 +36,18 @@ layout:
|
||||
rotation: 0
|
||||
```
|
||||
|
||||
```csv file=parts/seats.csv
|
||||
```csv file=seats.csv
|
||||
seat,x,y,rotation
|
||||
string,number,number,number
|
||||
0,40,0,0
|
||||
1,40,20,0
|
||||
```
|
||||
|
||||
```yaml file=setup/main.yaml
|
||||
role: setup
|
||||
type: game
|
||||
```yaml role=setup.game
|
||||
id: main
|
||||
setup:
|
||||
/dock/0: harbor:token#wood
|
||||
/deck: harbor:token#grain
|
||||
- path: /dock/0
|
||||
parts: harbor:token#wood
|
||||
- path: /deck
|
||||
parts: harbor:token#grain
|
||||
```
|
||||
|
||||
@@ -1,89 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadDefs, collectPackages } from './collect.js';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDefs, collectPackages } from "./collect.js";
|
||||
|
||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
|
||||
const multiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'vite-build', 'games');
|
||||
const fixtureRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"__fixtures__",
|
||||
"harbor",
|
||||
);
|
||||
const multiRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"__fixtures__",
|
||||
"vite-build",
|
||||
"games",
|
||||
);
|
||||
|
||||
describe('collectPackages', () => {
|
||||
it('collects the harbor package from markdown code blocks', () => {
|
||||
const defMap = loadDefs('', fixtureRoot);
|
||||
describe("collectPackages", () => {
|
||||
it("collects the harbor package from markdown code blocks", () => {
|
||||
const defMap = loadDefs("", fixtureRoot);
|
||||
const packages = collectPackages(defMap, fixtureRoot);
|
||||
|
||||
expect(packages).toHaveLength(1);
|
||||
const harbor = packages[0]!;
|
||||
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
||||
expect(harbor.meta).toMatchObject({
|
||||
id: "harbor",
|
||||
title: "Harbor",
|
||||
designer: "Jane Doe",
|
||||
});
|
||||
|
||||
// Two tokens from two yaml blocks sharing a `file=` name.
|
||||
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
||||
const wood = harbor.parts.get('token#wood')!;
|
||||
// Two tokens from two yaml blocks sharing a `role=part.token` name.
|
||||
expect([...harbor.parts.keys()].sort()).toEqual([
|
||||
"token#grain",
|
||||
"token#wood",
|
||||
]);
|
||||
const wood = harbor.parts.get("token#wood")!;
|
||||
expect(wood).toMatchObject({
|
||||
type: 'token',
|
||||
id: 'wood',
|
||||
type: "token",
|
||||
id: "wood",
|
||||
size: [20, 20, 3],
|
||||
fillet: 2,
|
||||
});
|
||||
expect(wood.face).toBe('./assets/tokens.png');
|
||||
expect(wood.face).toBe("./assets/tokens.png");
|
||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||
// Relative assets resolve against the source file's directory. The fixture
|
||||
// markdown sits at the games root, so the virtual file is `parts/tokens.yaml`.
|
||||
expect(wood.baseUrl).toBe('parts/');
|
||||
// Relative assets resolve against the markdown file's directory. The
|
||||
// fixture markdown sits at the games root, so baseUrl is empty.
|
||||
expect(wood.baseUrl).toBe("");
|
||||
|
||||
// Two surfaces: the table board and its child player board.
|
||||
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
||||
const board = harbor.surfaces.get('board#harbor')!;
|
||||
expect([...harbor.surfaces.keys()].sort()).toEqual([
|
||||
"board#harbor",
|
||||
"board#player",
|
||||
]);
|
||||
const board = harbor.surfaces.get("board#harbor")!;
|
||||
expect(board.size).toEqual([300, 200]);
|
||||
expect(board.mount).toEqual({ kind: 'table', x: 0, y: 0, rotation: 0 });
|
||||
expect(board.children).toEqual(['board#player']);
|
||||
expect(board.mount).toEqual({ kind: "table", x: 0, y: 0, rotation: 0 });
|
||||
expect(board.children).toEqual(["board#player"]);
|
||||
expect(board.layout).toHaveLength(2);
|
||||
const dock = board.layout[0]!;
|
||||
expect(dock.route).toBe('/dock/:seat');
|
||||
expect(dock.route).toBe("/dock/:seat");
|
||||
expect(dock.candidates).toEqual([
|
||||
{ seat: '0', x: 40, y: 0, rotation: 0 },
|
||||
{ seat: '1', x: 40, y: 20, rotation: 0 },
|
||||
{ seat: "0", x: 40, y: 0, rotation: 0 },
|
||||
{ seat: "1", x: 40, y: 20, rotation: 0 },
|
||||
]);
|
||||
const deck = board.layout[1]!;
|
||||
expect(deck.route).toBe('/deck');
|
||||
expect(deck.route).toBe("/deck");
|
||||
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
|
||||
|
||||
const player = harbor.surfaces.get('board#player')!;
|
||||
expect(player.mount).toEqual({ kind: 'child', x: 100, y: 50, rotation: 0 });
|
||||
const player = harbor.surfaces.get("board#player")!;
|
||||
expect(player.mount).toEqual({ kind: "child", x: 100, y: 50, rotation: 0 });
|
||||
expect(player.layout).toHaveLength(1);
|
||||
|
||||
// One setup, declaring the enabled surfaces.
|
||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
||||
const setup = harbor.setups.get('game#main')!;
|
||||
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
|
||||
expect(setup.setup).toEqual({
|
||||
'/dock/0': 'harbor:token#wood',
|
||||
'/deck': 'harbor:token#grain',
|
||||
});
|
||||
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
|
||||
const setup = harbor.setups.get("game#main")!;
|
||||
expect(setup.surfaces).toEqual(["board#harbor", "board#player"]);
|
||||
expect(setup.setup).toEqual([
|
||||
{ path: "/dock/0", parts: "harbor:token#wood" },
|
||||
{ path: "/deck", parts: "harbor:token#grain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes include patterns to the package declaration directory', () => {
|
||||
it("scopes include patterns to the package declaration directory", () => {
|
||||
// Two packages share a games root. Each uses the default `./**/*.yaml`
|
||||
// include, which must resolve relative to its own folder so neither
|
||||
// absorbs the other's defs (both define a `game#main` setup).
|
||||
const defMap = loadDefs('', multiRoot);
|
||||
const defMap = loadDefs("", multiRoot);
|
||||
const packages = collectPackages(defMap, multiRoot);
|
||||
|
||||
expect(packages).toHaveLength(2);
|
||||
const azul = packages.find((p) => p.meta.id === 'azul')!;
|
||||
const harbor = packages.find((p) => p.meta.id === 'harbor')!;
|
||||
const azul = packages.find((p) => p.meta.id === "azul")!;
|
||||
const harbor = packages.find((p) => p.meta.id === "harbor")!;
|
||||
|
||||
expect([...azul.parts.keys()]).toEqual(['tile#blue']);
|
||||
expect([...azul.setups.keys()]).toEqual(['game#main']);
|
||||
expect([...harbor.parts.keys()]).toEqual(['token#wood']);
|
||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
||||
expect([...azul.parts.keys()]).toEqual(["tile#blue"]);
|
||||
expect([...azul.setups.keys()]).toEqual(["game#main"]);
|
||||
expect([...harbor.parts.keys()]).toEqual(["token#wood"]);
|
||||
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
|
||||
});
|
||||
|
||||
it('throws on a duplicate type#id', () => {
|
||||
const defMap = loadDefs('', fixtureRoot);
|
||||
it("throws on a duplicate type#id", () => {
|
||||
const defMap = loadDefs("", fixtureRoot);
|
||||
// Inject a duplicate part into the map under a new file name.
|
||||
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('parts/tokens.yaml'))!;
|
||||
const tokensKey = [...defMap.defs.keys()].find((k) =>
|
||||
k.endsWith("part.token.yaml"),
|
||||
)!;
|
||||
const tokens = defMap.defs.get(tokensKey)!;
|
||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
|
||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
||||
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
|
||||
tokens[0]!,
|
||||
]);
|
||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
|
||||
/Duplicate part/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when an info-string id combines with $variants", () => {
|
||||
const defMap = loadDefs("", fixtureRoot);
|
||||
// A part whose `id` comes from $variants rows, but with an info-string id.
|
||||
const tokensKey = [...defMap.defs.keys()].find((k) =>
|
||||
k.endsWith("part.token.yaml"),
|
||||
)!;
|
||||
const tokens = defMap.defs.get(tokensKey)!;
|
||||
const variant = {
|
||||
...tokens[0]!,
|
||||
value: { ...tokens[0]!.value, id: undefined, $variants: "./seats.csv" },
|
||||
role: { role: "part" as const, type: "token", id: "wood" },
|
||||
};
|
||||
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
|
||||
variant,
|
||||
]);
|
||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
|
||||
/can't combine with \$variants/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+127
-47
@@ -11,17 +11,25 @@
|
||||
* their `include` patterns, and assembles the package's parts, surfaces,
|
||||
* and setups.
|
||||
*
|
||||
* See docs/bgm-format.md for the format's concrete behavior.
|
||||
* See docs/bgm/format.md for the format's concrete behavior.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { collectVirtualFiles } from './markdown.js';
|
||||
import { parseDefText, readDefFiles } from './parse.js';
|
||||
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
|
||||
import { expandVariants } from './variants.js';
|
||||
import * as path from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import { collectVirtualFiles } from "./markdown.js";
|
||||
import { parseDefText, readDefFiles } from "./parse.js";
|
||||
import {
|
||||
validateDialog,
|
||||
validatePackage,
|
||||
validatePart,
|
||||
validateSetup,
|
||||
validateSurface,
|
||||
} from "./schemas.js";
|
||||
import { expandVariants } from "./variants.js";
|
||||
import {
|
||||
BgmError,
|
||||
ROLES,
|
||||
type DefFile,
|
||||
type Dialog,
|
||||
type ParsedDef,
|
||||
type Package,
|
||||
type PackageDef,
|
||||
@@ -29,9 +37,7 @@ import {
|
||||
type Role,
|
||||
type Setup,
|
||||
type Surface,
|
||||
} from './types.js';
|
||||
|
||||
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
|
||||
} from "./types.js";
|
||||
|
||||
/** Every definition parsed from a def file, keyed by its path-style name. */
|
||||
export interface DefMap {
|
||||
@@ -53,7 +59,7 @@ export function loadDefs(root: string, rootDir: string): DefMap {
|
||||
const others: DefFile[] = [];
|
||||
|
||||
for (const file of realFiles) {
|
||||
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
|
||||
if (file.kind === "markdown") markdownFiles.set(file.name, file.text);
|
||||
else others.push(file);
|
||||
}
|
||||
|
||||
@@ -87,8 +93,12 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
||||
for (const [file, defs] of defMap.defs) {
|
||||
const list: ParsedDef[] = [];
|
||||
for (const def of defs) {
|
||||
const role = def.value['role'];
|
||||
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
|
||||
const role = def.value["role"];
|
||||
if (
|
||||
role !== undefined &&
|
||||
typeof role === "string" &&
|
||||
ROLES.has(role as Role)
|
||||
) {
|
||||
list.push(def);
|
||||
byRole.set(file, list);
|
||||
}
|
||||
@@ -98,10 +108,10 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
||||
const accs: PackageAcc[] = [];
|
||||
for (const [file, defs] of byRole) {
|
||||
for (const def of defs) {
|
||||
const role = def.value['role'] as Role;
|
||||
if (role === 'package') {
|
||||
const role = def.value["role"] as Role;
|
||||
if (role === "package") {
|
||||
const pkg = asPackage(def, file);
|
||||
const baseDir = path.posix.dirname(file).replace(/^\/+/, '');
|
||||
const baseDir = path.posix.dirname(file).replace(/^\/+/, "");
|
||||
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
|
||||
}
|
||||
}
|
||||
@@ -120,6 +130,7 @@ class PackageAcc {
|
||||
readonly parts = new Map<string, Part>();
|
||||
readonly surfaces = new Map<string, Surface>();
|
||||
readonly setups = new Map<string, Setup>();
|
||||
readonly dialogs = new Map<string, Dialog>();
|
||||
readonly byRole = new Map<string, string[]>();
|
||||
|
||||
constructor(
|
||||
@@ -131,23 +142,37 @@ class PackageAcc {
|
||||
) {}
|
||||
|
||||
collect() {
|
||||
const include = this.pkg.include ?? ['./**/*.yaml'];
|
||||
const include = this.pkg.include ?? ["./**/*.yaml"];
|
||||
const names = this.expandIncludes(include);
|
||||
for (const name of names) {
|
||||
const fileDefs = this.defs.defs.get(name);
|
||||
if (!fileDefs) continue;
|
||||
for (const def of fileDefs) {
|
||||
const role = def.value['role'];
|
||||
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
|
||||
const role = def.value["role"];
|
||||
if (
|
||||
typeof role !== "string" ||
|
||||
!ROLES.has(role as Role) ||
|
||||
role === "package"
|
||||
)
|
||||
continue;
|
||||
this.add(role as Role, def, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expand `$variants` on a def object into a list of concrete objects. */
|
||||
private expand(obj: Record<string, unknown>, baseName: string, source: string): Record<string, unknown>[] {
|
||||
if (!('$variants' in obj)) return [obj];
|
||||
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
|
||||
private expand(
|
||||
obj: Record<string, unknown>,
|
||||
baseDir: string,
|
||||
source: string,
|
||||
): Record<string, unknown>[] {
|
||||
if (!("$variants" in obj)) return [obj];
|
||||
const rows = expandVariants(
|
||||
obj["$variants"],
|
||||
baseNameFor(baseDir),
|
||||
this.defs.files,
|
||||
source,
|
||||
);
|
||||
const { $variants: _v, ...base } = obj;
|
||||
return rows.map((row) => ({ ...base, ...row }));
|
||||
}
|
||||
@@ -162,9 +187,15 @@ class PackageAcc {
|
||||
// patterns are prefixed to match.
|
||||
const names = new Set<string>();
|
||||
for (const pattern of patterns) {
|
||||
const resolved = pattern.startsWith('/')
|
||||
// A leading `/` marks a pattern as root-relative. Otherwise it's
|
||||
// relative to the package declaration's directory. When the package is
|
||||
// at the games root (empty baseDir), the pattern has no leading slash:
|
||||
// `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not.
|
||||
const resolved = pattern.startsWith("/")
|
||||
? pattern
|
||||
: `/${path.posix.join(this.baseDir, pattern)}`;
|
||||
: this.baseDir
|
||||
? `/${path.posix.join(this.baseDir, pattern)}`
|
||||
: path.posix.join(this.baseDir, pattern);
|
||||
const matcher = picomatch(resolved, { dot: true });
|
||||
for (const name of this.defs.defs.keys()) {
|
||||
if (matcher(name)) names.add(name);
|
||||
@@ -174,11 +205,19 @@ class PackageAcc {
|
||||
}
|
||||
|
||||
private add(role: Role, def: ParsedDef, fileName: string) {
|
||||
const expanded = this.expand(def.value, def.file, def.source);
|
||||
// `id` on the info string can't combine with `$variants`, since every
|
||||
// row supplies its own `id` and would override it.
|
||||
if (def.role?.id && "$variants" in def.value) {
|
||||
throw new BgmError(
|
||||
`id on the info string can't combine with $variants`,
|
||||
fileName,
|
||||
);
|
||||
}
|
||||
const expanded = this.expand(def.value, def.baseDir ?? "", def.source);
|
||||
for (const obj of expanded) {
|
||||
switch (role) {
|
||||
case 'part': {
|
||||
const part = asPart(obj, fileName);
|
||||
case "part": {
|
||||
const part = asPart(obj, def.baseDir ?? "");
|
||||
const key = `${part.type}#${part.id}`;
|
||||
if (this.parts.has(key)) {
|
||||
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
||||
@@ -186,8 +225,8 @@ class PackageAcc {
|
||||
this.parts.set(key, part);
|
||||
break;
|
||||
}
|
||||
case 'surface': {
|
||||
const surface = asSurface(obj, fileName, this.defs.files);
|
||||
case "surface": {
|
||||
const surface = asSurface(obj, def.baseDir ?? "", this.defs.files);
|
||||
const key = `${surface.type}#${surface.id}`;
|
||||
if (this.surfaces.has(key)) {
|
||||
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
||||
@@ -195,7 +234,7 @@ class PackageAcc {
|
||||
this.surfaces.set(key, surface);
|
||||
break;
|
||||
}
|
||||
case 'setup': {
|
||||
case "setup": {
|
||||
const setup = asSetup(obj, fileName);
|
||||
const key = `${setup.type}#${setup.id}`;
|
||||
if (this.setups.has(key)) {
|
||||
@@ -204,12 +243,27 @@ class PackageAcc {
|
||||
this.setups.set(key, setup);
|
||||
break;
|
||||
}
|
||||
case "dialog": {
|
||||
const dialog = asDialog(obj, fileName);
|
||||
const key = `${dialog.type}#${dialog.id}`;
|
||||
if (this.dialogs.has(key)) {
|
||||
throw new BgmError(`Duplicate dialog "${key}"`, fileName);
|
||||
}
|
||||
this.dialogs.set(key, dialog);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toPackage(): Package {
|
||||
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
|
||||
return {
|
||||
meta: metaOf(this.pkg),
|
||||
parts: this.parts,
|
||||
surfaces: this.surfaces,
|
||||
setups: this.setups,
|
||||
dialogs: this.dialogs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,36 +281,52 @@ function asPackage(def: ParsedDef, source: string): PackageDef {
|
||||
}
|
||||
}
|
||||
|
||||
function asPart(obj: Record<string, unknown>, source: string): Part {
|
||||
/** A base name whose directory is `baseDir`, for resolving `$variants` paths. */
|
||||
function baseNameFor(baseDir: string): string {
|
||||
const dir = baseDir.replace(/^\/+/, "");
|
||||
return dir ? `/${dir}/def.yaml` : "/def.yaml";
|
||||
}
|
||||
|
||||
function asPart(obj: Record<string, unknown>, baseDir: string): Part {
|
||||
try {
|
||||
const part = validatePart(obj) as unknown as Part;
|
||||
// Resolve relative asset paths against the directory of the source file
|
||||
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
||||
// Real files may carry a leading slash from an empty root; strip it.
|
||||
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
||||
part.baseUrl = dir ? `${dir}/` : '';
|
||||
// (path-style name relative to the games root). For a code block this is
|
||||
// the markdown file's directory; for a real file, its own directory.
|
||||
const dir = baseDir.replace(/^\/+/, "");
|
||||
part.baseUrl = dir ? `${dir}/` : "";
|
||||
return part;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
throw wrapZod(err, "");
|
||||
}
|
||||
}
|
||||
|
||||
function asSurface(
|
||||
obj: Record<string, unknown>,
|
||||
source: string,
|
||||
baseDir: string,
|
||||
defs: Map<string, DefFile[]>,
|
||||
): Surface {
|
||||
const value: Record<string, unknown> = { ...obj };
|
||||
delete value['role'];
|
||||
delete value["role"];
|
||||
|
||||
// Expand `candidates.$variants` on each route into a concrete array.
|
||||
if (Array.isArray(value['layout'])) {
|
||||
value['layout'] = value['layout'].map((route) => {
|
||||
if (typeof route !== 'object' || route === null) return route;
|
||||
if (Array.isArray(value["layout"])) {
|
||||
value["layout"] = value["layout"].map((route) => {
|
||||
if (typeof route !== "object" || route === null) return route;
|
||||
const r = route as Record<string, unknown>;
|
||||
const cand = r['candidates'];
|
||||
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
||||
const rows = expandVariants(cand['$variants'], source, defs, source);
|
||||
const cand = r["candidates"];
|
||||
if (
|
||||
cand &&
|
||||
typeof cand === "object" &&
|
||||
!Array.isArray(cand) &&
|
||||
"$variants" in cand
|
||||
) {
|
||||
const rows = expandVariants(
|
||||
cand["$variants"],
|
||||
baseNameFor(baseDir),
|
||||
defs,
|
||||
baseDir,
|
||||
);
|
||||
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||
}
|
||||
@@ -266,13 +336,13 @@ function asSurface(
|
||||
try {
|
||||
return validateSurface(value) as unknown as Surface;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
throw wrapZod(err, baseDir);
|
||||
}
|
||||
}
|
||||
|
||||
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
||||
const value: Record<string, unknown> = { ...obj };
|
||||
delete value['role'];
|
||||
delete value["role"];
|
||||
try {
|
||||
return validateSetup(value) as unknown as Setup;
|
||||
} catch (err) {
|
||||
@@ -280,6 +350,16 @@ function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
||||
}
|
||||
}
|
||||
|
||||
function asDialog(obj: Record<string, unknown>, source: string): Dialog {
|
||||
const value: Record<string, unknown> = { ...obj };
|
||||
delete value["role"];
|
||||
try {
|
||||
return validateDialog(value) as unknown as Dialog;
|
||||
} catch (err) {
|
||||
throw wrapZod(err, source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap a zod error with the source location. */
|
||||
function wrapZod(err: unknown, source: string): BgmError {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import { scanMarkdown } from './markdown.js';
|
||||
|
||||
describe('scanMarkdown', () => {
|
||||
it('extracts a fenced code block with a file= name', () => {
|
||||
it('names a block from its role= as role.type.lang', () => {
|
||||
const md = [
|
||||
'# Title',
|
||||
'',
|
||||
'```yaml file=parts/cargo.yaml',
|
||||
'role: part',
|
||||
'```yaml role=part.cargo',
|
||||
'id: wood',
|
||||
'```',
|
||||
'',
|
||||
'text after',
|
||||
@@ -17,26 +17,70 @@ describe('scanMarkdown', () => {
|
||||
|
||||
expect(fences).toHaveLength(1);
|
||||
expect(fences[0]).toMatchObject({
|
||||
info: 'yaml file=parts/cargo.yaml',
|
||||
content: 'role: part',
|
||||
info: 'yaml role=part.cargo',
|
||||
content: 'id: wood',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
});
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({
|
||||
name: 'harbor/parts/cargo.yaml',
|
||||
name: 'harbor/part.cargo.yaml',
|
||||
kind: 'yaml',
|
||||
text: 'role: part',
|
||||
text: 'id: wood',
|
||||
source: 'harbor/harbor.md:3-5',
|
||||
role: { role: 'part', type: 'cargo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-names a block without file= from its content hash', () => {
|
||||
it('names a package block package.yaml', () => {
|
||||
const md = '```yaml role=package\nid: harbor\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files[0]).toMatchObject({ name: 'harbor/package.yaml', role: { role: 'package' } });
|
||||
});
|
||||
|
||||
it('names a surface block with type and id', () => {
|
||||
const md = '```yaml role=surface.game#main\n```';
|
||||
const { files } = scanMarkdown(md, 'poker/poker.md');
|
||||
expect(files[0]).toMatchObject({
|
||||
name: 'poker/surface.game.yaml',
|
||||
role: { role: 'surface', type: 'game', id: 'main' },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses file= to override the role.type name', () => {
|
||||
const md = '```yaml file=parts/cargo.yaml role=part.cargo\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files[0]).toMatchObject({
|
||||
name: 'harbor/parts/cargo.yaml',
|
||||
role: { role: 'part', type: 'cargo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a block without role= or file=', () => {
|
||||
const md = '```yaml\nrole: part\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
|
||||
expect(files[0]!.kind).toBe('yaml');
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('names a csv block with file= as csv', () => {
|
||||
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
||||
});
|
||||
|
||||
it('throws on an unknown role=', () => {
|
||||
const md = '```yaml role=widget\n```';
|
||||
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Invalid role/);
|
||||
});
|
||||
|
||||
it('throws on role=package with a type', () => {
|
||||
const md = '```yaml role=package.foo\n```';
|
||||
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Package role takes no type/);
|
||||
});
|
||||
|
||||
it('throws on a role without a type', () => {
|
||||
const md = '```yaml role=part\n```';
|
||||
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/requires a type/);
|
||||
});
|
||||
|
||||
it('ignores non-definition languages', () => {
|
||||
@@ -52,23 +96,15 @@ describe('scanMarkdown', () => {
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('names a csv block with file= as csv', () => {
|
||||
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
||||
});
|
||||
|
||||
it('tracks line numbers across multiple blocks', () => {
|
||||
const md = [
|
||||
'```yaml file=a.yaml',
|
||||
'role: part',
|
||||
'```yaml role=part.a',
|
||||
'```',
|
||||
'',
|
||||
'```yaml file=b.yaml',
|
||||
'role: part',
|
||||
'```yaml role=part.b',
|
||||
'```',
|
||||
].join('\n');
|
||||
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||
expect(fences.map((f) => f.startLine)).toEqual([1, 5]);
|
||||
expect(fences.map((f) => f.startLine)).toEqual([1, 4]);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Extract virtual definition files from markdown code blocks.
|
||||
*
|
||||
* Each fenced code block is a virtual definition file:
|
||||
* - With a `file=` segment in its info string, named relative to the
|
||||
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
|
||||
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
|
||||
* block is discoverable by the default include pattern (all yaml in the
|
||||
* same and sub folders). Identical blocks dedupe to the same hash.
|
||||
* Each fenced code block is a virtual definition file. A block is a
|
||||
* definition only when its info string declares a `role=` (or a `file=`):
|
||||
* - With `role=part.cargo`, named `part.cargo.yaml` (the `role.type.lang`
|
||||
* form), discoverable by the default include pattern (all yaml in the
|
||||
* same and sub folders).
|
||||
* - With `file=parts/cargo.yaml`, named that path regardless of its role.
|
||||
* - Without either, the block is not a definition and is ignored.
|
||||
*
|
||||
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
||||
* virtual file.
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import { posix } from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { BgmError, type DefFile } from './types.js';
|
||||
import { BgmError, ROLES, roleToName, type DefFile, type Role, type RoleMeta } from './types.js';
|
||||
|
||||
/** The languages that count as definition files; others are ignored. */
|
||||
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
||||
@@ -25,7 +25,7 @@ export interface Fence {
|
||||
startLine: number;
|
||||
/** Line number (1-based) of the closing fence. */
|
||||
endLine: number;
|
||||
/** The info string content (e.g. `yaml file=parts/cargo.yaml`). */
|
||||
/** The info string content (e.g. `yaml role=part.cargo`). */
|
||||
info: string;
|
||||
/** The code block's content (without the fences). */
|
||||
content: string;
|
||||
@@ -60,13 +60,17 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
||||
|
||||
fences.push({ startLine, endLine, info, content: token.text });
|
||||
|
||||
const name = parseInfo(info);
|
||||
if (name) {
|
||||
const parsed = parseInfo(info, `${sourcePath}:${startLine}`);
|
||||
if (parsed) {
|
||||
files.push({
|
||||
name: posix.join(posix.dirname(sourcePath), name),
|
||||
name: posix.join(posix.dirname(sourcePath), parsed.name),
|
||||
text: token.text,
|
||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
||||
kind: kindOf(name),
|
||||
kind: parsed.kind,
|
||||
role: parsed.role,
|
||||
// Relative asset paths resolve against the markdown file's directory,
|
||||
// not the virtual `role.type` name (which has no directory).
|
||||
baseDir: posix.dirname(sourcePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -74,17 +78,54 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
||||
return { fences, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fence's info string for a `file=` segment and derive the virtual
|
||||
* file name. Blocks without `file=` are auto-named from their content hash.
|
||||
*/
|
||||
function parseInfo(info: string): string | null {
|
||||
const fileMatch = /file=(\S+)/.exec(info);
|
||||
if (fileMatch) return fileMatch[1]!;
|
||||
interface ParsedInfo {
|
||||
name: string;
|
||||
kind: DefFile['kind'];
|
||||
role?: RoleMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fence's info string into a virtual file name and role metadata.
|
||||
* A block is a definition when it has a `role=` or a `file=`. `file=`
|
||||
* overrides the auto `role.type.lang` name.
|
||||
*/
|
||||
function parseInfo(info: string, source: string): ParsedInfo | null {
|
||||
const lang = info.split(/\s+/)[0];
|
||||
if (!lang || !DEF_LANGS.has(lang)) return null;
|
||||
return `./${hash(info)}.yaml`;
|
||||
const fileMatch = /file=(\S+)/.exec(info);
|
||||
const role = parseRole(info, source);
|
||||
|
||||
if (fileMatch) {
|
||||
const name = fileMatch[1]!;
|
||||
return { name, kind: kindOf(name), role };
|
||||
}
|
||||
if (!role) return null;
|
||||
if (!lang || !DEF_LANGS.has(lang)) {
|
||||
throw new BgmError(`Definition block needs a definition language tag`, source);
|
||||
}
|
||||
return { name: roleToName(role, extOf(lang)), kind: kindOfLang(lang), role };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fence's info string for a `role=` segment into role metadata.
|
||||
* `role=part.cargo` -> `{ role: 'part', type: 'cargo' }`;
|
||||
* `role=surface.game#main` -> `{ role: 'surface', type: 'game', id: 'main' }`;
|
||||
* `role=package` -> `{ role: 'package' }`. Returns `undefined` when absent.
|
||||
*/
|
||||
function parseRole(info: string, source: string): RoleMeta | undefined {
|
||||
const match = /role=(\S+)/.exec(info);
|
||||
if (!match) return undefined;
|
||||
const spec = match[1]!;
|
||||
const [role, rest] = spec.split('.');
|
||||
if (!role || !ROLES.has(role as Role)) {
|
||||
throw new BgmError(`Invalid role "${spec}"`, source);
|
||||
}
|
||||
if (role === 'package') {
|
||||
if (rest) throw new BgmError(`Package role takes no type or id`, source);
|
||||
return { role: 'package' };
|
||||
}
|
||||
const [type, id] = (rest ?? '').split('#');
|
||||
if (!type) throw new BgmError(`Role "${role}" requires a type`, source);
|
||||
return { role: role as Role, type, id: id || undefined };
|
||||
}
|
||||
|
||||
/** Derive the def file type from its name's extension. */
|
||||
@@ -96,9 +137,21 @@ function kindOf(name: string): DefFile['kind'] {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
/** A stable content hash for auto-named blocks. */
|
||||
function hash(text: string): string {
|
||||
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
|
||||
/** The file extension for a definition language tag. */
|
||||
function extOf(lang: string): string {
|
||||
return lang === 'yml' ? 'yml' : lang;
|
||||
}
|
||||
|
||||
/** The def file kind for a definition language tag. */
|
||||
function kindOfLang(lang: string): DefFile['kind'] {
|
||||
switch (lang) {
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'toml':
|
||||
return 'toml';
|
||||
default:
|
||||
return 'yaml';
|
||||
}
|
||||
}
|
||||
|
||||
/** The 1-based line number where `raw` starts within `text`. */
|
||||
@@ -110,9 +163,8 @@ function lineOf(text: string, raw: string): number {
|
||||
|
||||
/**
|
||||
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
||||
* Multiple blocks may share a name (e.g. several `file=parts/tokens.yaml`
|
||||
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
|
||||
* same hash name.
|
||||
* Multiple blocks may share a name (e.g. several `role=part.token` blocks);
|
||||
* each is kept as a separate entry.
|
||||
*/
|
||||
export type VirtualFiles = Map<string, DefFile[]>;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseDefText } from './parse.js';
|
||||
import type { DefFile } from './types.js';
|
||||
|
||||
function file(overrides: Partial<DefFile> = {}): DefFile {
|
||||
return {
|
||||
name: 'poker/cards.yaml',
|
||||
text: 'id: 2s',
|
||||
source: 'poker/poker.md:3-5',
|
||||
kind: 'yaml',
|
||||
baseDir: 'poker',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseDefText', () => {
|
||||
it('merges role= metadata into the parsed object', () => {
|
||||
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
|
||||
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'poker-card', id: '2s' });
|
||||
});
|
||||
|
||||
it('merges role= with type and id', () => {
|
||||
const defs = parseDefText(
|
||||
file({ text: 'id: main', role: { role: 'surface', type: 'game', id: 'main' } }),
|
||||
);
|
||||
expect(defs[0]!.value).toMatchObject({ role: 'surface', type: 'game', id: 'main' });
|
||||
});
|
||||
|
||||
it('merges role= into every item of a list block', () => {
|
||||
const defs = parseDefText(
|
||||
file({
|
||||
text: '- id: a\n- id: b',
|
||||
role: { role: 'part', type: 'tile' },
|
||||
}),
|
||||
);
|
||||
expect(defs.map((d) => d.value)).toEqual([
|
||||
{ role: 'part', type: 'tile', id: 'a' },
|
||||
{ role: 'part', type: 'tile', id: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the role metadata on the parsed def', () => {
|
||||
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
|
||||
expect(defs[0]!.role).toEqual({ role: 'part', type: 'poker-card' });
|
||||
});
|
||||
|
||||
it('carries the base directory on the parsed def', () => {
|
||||
const defs = parseDefText(file({ baseDir: 'poker/parts' }));
|
||||
expect(defs[0]!.baseDir).toBe('poker/parts');
|
||||
});
|
||||
|
||||
it('errors on a role conflict with the content', () => {
|
||||
expect(() =>
|
||||
parseDefText(file({ text: 'role: surface', role: { role: 'part' } })),
|
||||
).toThrow(/Conflicting role/);
|
||||
});
|
||||
|
||||
it('errors on a type conflict with the content', () => {
|
||||
expect(() =>
|
||||
parseDefText(file({ text: 'type: tile', role: { role: 'part', type: 'card' } })),
|
||||
).toThrow(/Conflicting type/);
|
||||
});
|
||||
|
||||
it('errors on an id conflict with the content', () => {
|
||||
expect(() =>
|
||||
parseDefText(file({ text: 'id: 2s', role: { role: 'part', type: 'card', id: '3s' } })),
|
||||
).toThrow(/Conflicting id/);
|
||||
});
|
||||
|
||||
it('accepts matching role metadata and content', () => {
|
||||
const defs = parseDefText(
|
||||
file({ text: 'role: part\ntype: card', role: { role: 'part', type: 'card' } }),
|
||||
);
|
||||
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'card' });
|
||||
});
|
||||
|
||||
it('leaves the object unchanged without role metadata', () => {
|
||||
const defs = parseDefText(file({ text: 'role: part\ntype: card\nid: 2s' }));
|
||||
expect(defs[0]!.value).toEqual({ role: 'part', type: 'card', id: '2s' });
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,22 @@
|
||||
* Parse raw definition files (yaml/json/toml text) into JSON objects.
|
||||
*
|
||||
* A def file's document can be either a single JSON object (the root) or a
|
||||
* list of objects; both are handled per docs/bgm-format.md §3. In list mode,
|
||||
* list of objects; both are handled per docs/bgm/format.md §3. In list mode,
|
||||
* each object is a separate definition.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { BgmError, type DefFile, type ParsedDef } from './types.js';
|
||||
import { BgmError, roleFromName, type DefFile, type ParsedDef } from './types.js';
|
||||
|
||||
/**
|
||||
* Parse a def file's text into a list of definition objects.
|
||||
*
|
||||
* A code block's `role=` (or a real file's `role.type` name) metadata is
|
||||
* merged into every parsed object, erroring on a conflict with the same key
|
||||
* in the content.
|
||||
*
|
||||
* @returns the parsed objects; the root object (index `-1`) or the list
|
||||
* items (index `0..n`)
|
||||
*/
|
||||
@@ -33,7 +37,8 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
||||
|
||||
const push = (value: unknown, index: number) => {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source });
|
||||
const merged = mergeRole(file, value as Record<string, unknown>);
|
||||
out.push({ file: file.name, index, value: merged, source: file.source, role: file.role, baseDir: file.baseDir });
|
||||
} else if (value !== null) {
|
||||
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
||||
}
|
||||
@@ -47,6 +52,27 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge a definition's `role=`/filename metadata into a parsed object, erroring on conflict. */
|
||||
function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string, unknown> {
|
||||
const meta = file.role;
|
||||
if (!meta) return value;
|
||||
const out = { ...value };
|
||||
const keys: Array<[key: string, fromMeta: string | undefined]> = [
|
||||
['role', meta.role],
|
||||
['type', meta.type],
|
||||
['id', meta.id],
|
||||
];
|
||||
for (const [key, fromMeta] of keys) {
|
||||
if (fromMeta === undefined) continue;
|
||||
const fromContent = out[key];
|
||||
if (fromContent !== undefined && fromContent !== fromMeta) {
|
||||
throw new BgmError(`Conflicting ${key}: "${fromMeta}" in the name vs "${fromContent}" in content`, file.source);
|
||||
}
|
||||
out[key] = fromMeta;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseText(kind: DefFile['kind'], text: string): unknown {
|
||||
switch (kind) {
|
||||
case 'json':
|
||||
@@ -92,6 +118,8 @@ export function readDefFiles(dir: string, root: string): DefFile[] {
|
||||
text: fs.readFileSync(abs, 'utf8'),
|
||||
source: abs,
|
||||
kind,
|
||||
role: roleFromName(entry.name),
|
||||
baseDir: root && relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* These validate the raw definition objects (after `$variants` expansion)
|
||||
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
|
||||
* See docs/bgm-format.md for the format's concrete behavior.
|
||||
* See docs/bgm/format.md for the format's concrete behavior.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { PackageDef, Part, Setup, Surface } from './types.js';
|
||||
import { z } from "zod";
|
||||
import type { Dialog, PackageDef, Part, Setup, Surface } from "./types.js";
|
||||
|
||||
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
||||
const size = z.tuple([z.number(), z.number(), z.number()]);
|
||||
@@ -15,7 +15,7 @@ const surfaceSize = z.tuple([z.number(), z.number()]);
|
||||
const stacking = z.object({
|
||||
curve: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
align: z.enum(['start', 'end', 'center']).optional(),
|
||||
align: z.enum(["start", "end", "center"]).optional(),
|
||||
steps: z.number().optional(),
|
||||
tilt: z.number().optional(),
|
||||
zStart: z.number().optional(),
|
||||
@@ -44,7 +44,7 @@ const partSchema = z.object({
|
||||
});
|
||||
|
||||
const surfaceMount = z.object({
|
||||
kind: z.enum(['table', 'hud', 'child']),
|
||||
kind: z.enum(["table", "hud", "child"]),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
rotation: z.number().optional(),
|
||||
@@ -60,15 +60,43 @@ const surfaceSchema = z.object({
|
||||
layout: z.array(route),
|
||||
});
|
||||
|
||||
const setupValue = z.union([z.string(), z.array(z.string())]);
|
||||
|
||||
const setupPlacement = z.object({
|
||||
path: z.string(),
|
||||
parts: setupValue,
|
||||
facing: z.enum(["face", "back", "standing"]).optional(),
|
||||
});
|
||||
|
||||
const interaction = z.object({
|
||||
dialog: z.string().min(1),
|
||||
on: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
surfaces: z.array(z.string()).optional(),
|
||||
setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
|
||||
setup: z.array(setupPlacement),
|
||||
interactions: z.array(interaction).optional(),
|
||||
});
|
||||
|
||||
const dialogAction = z.object({
|
||||
label: z.string().min(1),
|
||||
command: z.unknown(),
|
||||
});
|
||||
|
||||
const dialogSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
actions: z.array(dialogAction).optional(),
|
||||
widget: z.string().optional(),
|
||||
});
|
||||
|
||||
const packageSchema = z.object({
|
||||
role: z.literal('package'),
|
||||
role: z.literal("package"),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
designer: z.string().optional(),
|
||||
@@ -94,6 +122,11 @@ export function validateSetup(value: Record<string, unknown>): Setup {
|
||||
return setupSchema.parse(value) as unknown as Setup;
|
||||
}
|
||||
|
||||
/** Validate a raw dialog definition. */
|
||||
export function validateDialog(value: Record<string, unknown>): Dialog {
|
||||
return dialogSchema.parse(value) as unknown as Dialog;
|
||||
}
|
||||
|
||||
/** Validate a raw package definition. */
|
||||
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
||||
return packageSchema.parse(value) as unknown as PackageDef;
|
||||
|
||||
+141
-16
@@ -5,11 +5,11 @@
|
||||
* discovered as JSON objects from yaml/json/toml files and from markdown
|
||||
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
|
||||
*
|
||||
* The concrete behavior of the format is described in `docs/bgm-format.md`.
|
||||
* The concrete behavior of the format is described in `docs/bgm/format.md`.
|
||||
*/
|
||||
|
||||
/** Part value types. */
|
||||
export type PartValueType = 'image' | 'crop' | 'size' | 'sprite';
|
||||
export type PartValueType = "image" | "crop" | "size" | "sprite";
|
||||
|
||||
/**
|
||||
* A crop tuple `[col, row, cols, rows]`. Divides the image into a
|
||||
@@ -106,7 +106,7 @@ export interface Stacking {
|
||||
/** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
|
||||
limit?: number;
|
||||
/** `start`, `end`, or `center` of the curve. */
|
||||
align?: 'start' | 'end' | 'center';
|
||||
align?: "start" | "end" | "center";
|
||||
/** Maximum parts per curve length unit; defaults to `1`. */
|
||||
steps?: number;
|
||||
/**
|
||||
@@ -126,7 +126,7 @@ export interface Stacking {
|
||||
}
|
||||
|
||||
/** How a surface is mounted. `kind` selects the mount type. */
|
||||
export type SurfaceMountKind = 'table' | 'hud' | 'child';
|
||||
export type SurfaceMountKind = "table" | "hud" | "child";
|
||||
|
||||
/**
|
||||
* How a surface is mounted. Always an object, anchored by `x`/`y`/`rotation`
|
||||
@@ -157,16 +157,118 @@ export interface Surface {
|
||||
|
||||
export type SetupValue = string | string[];
|
||||
|
||||
/** Seeds the state store: the enabled surfaces and a map from path to parts. */
|
||||
/**
|
||||
* How a part is oriented on the board. `face` lays it flat front-up, `back`
|
||||
* flips it over front-down, and `standing` stands it on its bottom edge.
|
||||
*/
|
||||
export type Facing = "face" | "back" | "standing";
|
||||
|
||||
/**
|
||||
* One setup placement: move `parts` to `path`. Entries are applied in order,
|
||||
* so a part listed in a later entry ends up on that entry's path.
|
||||
*/
|
||||
export interface SetupPlacement {
|
||||
/** The path key to place the parts on. */
|
||||
path: string;
|
||||
/** Parts to place: a part id, a bare type (expands to all of that type), or a list of either. */
|
||||
parts: SetupValue;
|
||||
/** Initial facing for the placed parts; defaults to `face`. */
|
||||
facing?: Facing;
|
||||
}
|
||||
|
||||
/** Seeds the state store: the enabled surfaces and an ordered list of placements. */
|
||||
export interface Setup {
|
||||
type: string;
|
||||
id: string;
|
||||
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
||||
surfaces?: string[];
|
||||
setup: Record<string, SetupValue>;
|
||||
/** Ordered placements; each moves its parts to its path. */
|
||||
setup: SetupPlacement[];
|
||||
/**
|
||||
* Interaction affordances: which dialogs are the tool for which open
|
||||
* interactions on which paths. Declares the interaction surface, not the
|
||||
* legality of the resulting command (rules gate that, later).
|
||||
*/
|
||||
interactions?: Interaction[];
|
||||
}
|
||||
|
||||
export type Role = 'package' | 'part' | 'surface' | 'setup';
|
||||
/**
|
||||
* One interaction affordance: a `role: dialog` ref (`type#id`) and the paths
|
||||
* it applies to. `on` omitted means the dialog applies to any path.
|
||||
*/
|
||||
export interface Interaction {
|
||||
/** A `role: dialog` ref (`type#id`). */
|
||||
dialog: string;
|
||||
/** Paths this interaction applies to; omitted = any path. */
|
||||
on?: string[];
|
||||
}
|
||||
|
||||
/** A dialog's action button: a label and the command it issues. */
|
||||
export interface DialogAction {
|
||||
label: string;
|
||||
/** The command the button issues (e.g. a `move`); the rule seam gates it. */
|
||||
command: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `role: dialog` definition: declarative content shown in the layer-3 shell.
|
||||
* Opening/closing it never issues a command or mutates state; its action
|
||||
* buttons issue commands. `widget` selects the content type (e.g. `stack`).
|
||||
*/
|
||||
export interface Dialog {
|
||||
type: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
/** Action buttons; each issues a command. */
|
||||
actions?: DialogAction[];
|
||||
/** Content widget type, e.g. `stack` for a stack-of-parts view. */
|
||||
widget?: string;
|
||||
/** Extra fields from the source definition, kept for forwards compatibility. */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type Role = "package" | "part" | "surface" | "setup" | "dialog";
|
||||
|
||||
/** The five definition roles. */
|
||||
export const ROLES: ReadonlySet<Role> = new Set([
|
||||
"package",
|
||||
"part",
|
||||
"surface",
|
||||
"setup",
|
||||
"dialog",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Role metadata declared on a code block's info string (`role=part.cargo`) or
|
||||
* a real file's name (`part.cargo.yaml`). `type` is required for all roles
|
||||
* except `package`; `id` is optional — anything not given comes from the
|
||||
* content or from `$variants` rows.
|
||||
*/
|
||||
export interface RoleMeta {
|
||||
role: Role;
|
||||
type?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** The canonical file name for a role, e.g. `part.cargo.yaml`. */
|
||||
export function roleToName(role: RoleMeta, ext = "yaml"): string {
|
||||
if (role.role === "package") return `package.${ext}`;
|
||||
return `${role.role}.${role.type}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `role.type.lang` file name into role metadata, or `undefined` when
|
||||
* the name is not a definition (`package.yaml`, `part.cargo.yaml`, ...).
|
||||
*/
|
||||
export function roleFromName(name: string): RoleMeta | undefined {
|
||||
if (/^package\.(ya?ml|json|toml)$/i.test(name)) return { role: "package" };
|
||||
const m = /^(\w+)\.(.+)\.(ya?ml|json|toml)$/i.exec(name);
|
||||
if (m && ROLES.has(m[1] as Role) && m[1] !== "package") {
|
||||
return { role: m[1] as Role, type: m[2] };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw definition object as written by the author. Definitions can be the
|
||||
@@ -177,25 +279,29 @@ export interface RawDef {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The four definition roles. */
|
||||
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef;
|
||||
/** The five definition roles. */
|
||||
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef | DialogDef;
|
||||
|
||||
export interface PackageDef extends PackageMeta {
|
||||
role: 'package';
|
||||
role: "package";
|
||||
/** Git-style path patterns of the defs that make up the package. */
|
||||
include?: string[];
|
||||
}
|
||||
|
||||
export interface PartDef extends Part {
|
||||
role: 'part';
|
||||
role: "part";
|
||||
}
|
||||
|
||||
export interface SurfaceDef extends Surface {
|
||||
role: 'surface';
|
||||
role: "surface";
|
||||
}
|
||||
|
||||
export interface SetupDef extends Setup {
|
||||
role: 'setup';
|
||||
role: "setup";
|
||||
}
|
||||
|
||||
export interface DialogDef extends Dialog {
|
||||
role: "dialog";
|
||||
}
|
||||
|
||||
/** A virtual definition file: a real file or a markdown code block. */
|
||||
@@ -207,7 +313,15 @@ export interface DefFile {
|
||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||
source: string;
|
||||
/** File type derived from the name's extension. */
|
||||
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
||||
kind: "yaml" | "json" | "toml" | "markdown" | "csv";
|
||||
/** Role declared on a code block's info string or a real file's name. */
|
||||
role?: RoleMeta;
|
||||
/**
|
||||
* Directory (path-style, relative to the games root) that relative asset
|
||||
* paths resolve against. For a code block, the markdown file's directory;
|
||||
* for a real file, its own directory.
|
||||
*/
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
/** A single parsed definition (one JSON object from a def file). */
|
||||
@@ -219,6 +333,10 @@ export interface ParsedDef {
|
||||
value: Record<string, unknown>;
|
||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||
source: string;
|
||||
/** Role declared on the code block's info string or a real file's name. */
|
||||
role?: RoleMeta;
|
||||
/** Directory relative asset paths resolve against (see `DefFile.baseDir`). */
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +351,8 @@ export interface Package {
|
||||
surfaces: Map<string, Surface>;
|
||||
/** All setups by `type#id`. */
|
||||
setups: Map<string, Setup>;
|
||||
/** All dialogs by `type#id`. */
|
||||
dialogs: Map<string, Dialog>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,12 +368,17 @@ export interface SerializedPackage {
|
||||
surfaces: Record<string, Surface>;
|
||||
/** All setups by `type#id`. */
|
||||
setups: Record<string, Setup>;
|
||||
/** All dialogs by `type#id`. */
|
||||
dialogs: Record<string, Dialog>;
|
||||
}
|
||||
|
||||
/** Errors during loading, carrying the source location when available. */
|
||||
export class BgmError extends Error {
|
||||
constructor(message: string, readonly location?: string) {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly location?: string,
|
||||
) {
|
||||
super(location ? `${location}: ${message}` : message);
|
||||
this.name = 'BgmError';
|
||||
this.name = "BgmError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('parseCsvData', () => {
|
||||
});
|
||||
|
||||
describe('expandVariants', () => {
|
||||
it('parses inline CSV when the value contains a newline', () => {
|
||||
it('parses inline CSV when the first line does not end in .csv', () => {
|
||||
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
||||
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
||||
});
|
||||
@@ -61,15 +61,30 @@ describe('expandVariants', () => {
|
||||
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
||||
});
|
||||
|
||||
it('concatenates rows from an array of csv paths', () => {
|
||||
const defs = new Map<string, DefFile[]>([
|
||||
['pkg/parts/a.csv', [defFile('pkg/parts/a.csv', 'id\nstring\nred')]],
|
||||
['pkg/parts/b.csv', [defFile('pkg/parts/b.csv', 'id\nstring\nblack')]],
|
||||
]);
|
||||
const rows = expandVariants(['./a.csv', './b.csv'], 'pkg/parts/board.yaml', defs, 'src');
|
||||
expect(rows).toEqual([{ id: 'red' }, { id: 'black' }]);
|
||||
});
|
||||
|
||||
it('throws when the referenced csv is missing', () => {
|
||||
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||
/CSV not found/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when $variants is not a string', () => {
|
||||
it('throws when $variants is not a string or string array', () => {
|
||||
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||
/must be a path or inline CSV/,
|
||||
/must be a path or inline CSV string, or an array of them/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on an empty $variants array', () => {
|
||||
expect(() => expandVariants([], 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||
/must not be empty/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,15 @@
|
||||
* The `$variants` directive: parse a CSV into a typed object array and
|
||||
* extend the original object with each row.
|
||||
*
|
||||
* Per docs/bgm-format.md §1:
|
||||
* Per docs/bgm/format.md §1:
|
||||
* - The CSV's first row is the header, the second row is the type declaration
|
||||
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
|
||||
* the remaining rows are data.
|
||||
* - Rows are validated against a schema derived from the type row.
|
||||
* - A cell for an array/tuple type uses `;` as the element separator
|
||||
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
||||
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
|
||||
* containing a newline is inline CSV, otherwise it is a path.
|
||||
* - `$variants` can be a single source or an array of them. A source is a
|
||||
* file/URL path if its first line ends in `.csv`, otherwise inline CSV.
|
||||
*
|
||||
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
||||
* this header/schema/data layout and validates each row against a schema
|
||||
@@ -74,7 +74,8 @@ export function parseCsvByName(
|
||||
/**
|
||||
* Expand a `$variants` value into rows.
|
||||
*
|
||||
* @param value the `$variants` value: a path or inline CSV
|
||||
* @param value the `$variants` value: a path or inline CSV string, or an
|
||||
* array of them
|
||||
* @param baseName the path-style name of the referencing def file; a path
|
||||
* value resolves relative to its directory
|
||||
* @param defs the virtual def map, for resolving the path
|
||||
@@ -86,14 +87,41 @@ export function expandVariants(
|
||||
defs: Map<string, DefFile[]>,
|
||||
source: string,
|
||||
): Record<string, unknown>[] {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BgmError('`$variants` must be a path or inline CSV string', source);
|
||||
const sources = Array.isArray(value) ? value : [value];
|
||||
if (sources.length === 0) {
|
||||
throw new BgmError('`$variants` array must not be empty', source);
|
||||
}
|
||||
|
||||
if (value.includes('\n')) {
|
||||
return parseCsvData(value, source).rows;
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (const item of sources) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new BgmError(
|
||||
'`$variants` must be a path or inline CSV string, or an array of them',
|
||||
source,
|
||||
);
|
||||
}
|
||||
rows.push(...expandVariantsOne(item, baseName, defs, source));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const name = path.posix.join(path.posix.dirname(baseName), value);
|
||||
return parseCsvByName(name, defs, source).rows;
|
||||
/**
|
||||
* Expand a single `$variants` source: a path or inline CSV.
|
||||
*
|
||||
* A source is a path when its first line ends in `.csv`; otherwise it is
|
||||
* inline CSV. This keeps the two forms self-documenting and applies the same
|
||||
* rule to single values and array elements alike.
|
||||
*/
|
||||
function expandVariantsOne(
|
||||
value: string,
|
||||
baseName: string,
|
||||
defs: Map<string, DefFile[]>,
|
||||
source: string,
|
||||
): Record<string, unknown>[] {
|
||||
const firstLine = value.split('\n', 1)[0] ?? value;
|
||||
if (/[.]csv$/i.test(firstLine)) {
|
||||
const name = path.posix.join(path.posix.dirname(baseName), value);
|
||||
return parseCsvByName(name, defs, source).rows;
|
||||
}
|
||||
return parseCsvData(value, source).rows;
|
||||
}
|
||||
+16
-13
@@ -15,17 +15,17 @@
|
||||
* be mistaken for real installed packages. Editing a game definition
|
||||
* hot-reloads the app via `addWatchFile`.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { normalizePath, type ModuleNode, type Plugin } from 'vite';
|
||||
import { collectPackages, loadDefs } from './collect.js';
|
||||
import { readDefFiles } from './parse.js';
|
||||
import type { Package, SerializedPackage } from './types.js';
|
||||
import * as path from "node:path";
|
||||
import { normalizePath, type ModuleNode, type Plugin } from "vite";
|
||||
import { collectPackages, loadDefs } from "./collect.js";
|
||||
import { readDefFiles } from "./parse.js";
|
||||
import type { Package, SerializedPackage } from "./types.js";
|
||||
|
||||
const VIRTUAL_PREFIX = '\0bgm:';
|
||||
const VIRTUAL_PREFIX = "\0bgm:";
|
||||
/** Public specifier for the module that lists every package. */
|
||||
const PACKAGES = 'virtual:bgm/packages';
|
||||
const PACKAGES = "virtual:bgm/packages";
|
||||
/** Public specifier prefix for a single package module. */
|
||||
const PACKAGE = 'virtual:bgm/package/';
|
||||
const PACKAGE = "virtual:bgm/package/";
|
||||
|
||||
export interface BgmOptions {
|
||||
/** Absolute path to the games root (e.g. `<repo>/games`). */
|
||||
@@ -39,23 +39,23 @@ export function bgm(options: BgmOptions): Plugin {
|
||||
const root = normalizePath(options.root);
|
||||
|
||||
const collect = (): Package[] => {
|
||||
const defMap = loadDefs('', root);
|
||||
const defMap = loadDefs("", root);
|
||||
return collectPackages(defMap, root);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'bgm',
|
||||
name: "bgm",
|
||||
buildStart() {
|
||||
// Watch every real definition source under the games root so edits
|
||||
// trigger a re-collect. `defMap.files` only holds the virtual code-block
|
||||
// files plus real non-markdown files; the markdown files themselves are
|
||||
// consumed for their code blocks and never appear there, so watch the
|
||||
// real files on disk too (markdown and anything else the loader reads).
|
||||
const defMap = loadDefs('', root);
|
||||
const defMap = loadDefs("", root);
|
||||
for (const name of defMap.files.keys()) {
|
||||
this.addWatchFile(path.join(root, name));
|
||||
}
|
||||
for (const file of readDefFiles(root, '')) {
|
||||
for (const file of readDefFiles(root, "")) {
|
||||
this.addWatchFile(file.source);
|
||||
}
|
||||
},
|
||||
@@ -68,7 +68,9 @@ export function bgm(options: BgmOptions): Plugin {
|
||||
// changes, so edits hot-reload instead of requiring a manual refresh.
|
||||
if (!ctx.file.startsWith(root)) return;
|
||||
const invalidated: ModuleNode[] = [];
|
||||
const mod = ctx.server.moduleGraph.getModuleById(VIRTUAL_PREFIX + PACKAGES);
|
||||
const mod = ctx.server.moduleGraph.getModuleById(
|
||||
VIRTUAL_PREFIX + PACKAGES,
|
||||
);
|
||||
if (mod) {
|
||||
ctx.server.moduleGraph.invalidateModule(mod);
|
||||
invalidated.push(mod);
|
||||
@@ -102,5 +104,6 @@ function toJson(pkg: Package): SerializedPackage {
|
||||
parts: Object.fromEntries(pkg.parts),
|
||||
surfaces: Object.fromEntries(pkg.surfaces),
|
||||
setups: Object.fromEntries(pkg.setups),
|
||||
dialogs: Object.fromEntries(pkg.dialogs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bgm } from './vite.js';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { bgm } from "./vite.js";
|
||||
|
||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
|
||||
const gamesRoot = path.join(fixtureRoot, 'harbor');
|
||||
const fixtureRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"__fixtures__",
|
||||
);
|
||||
const gamesRoot = path.join(fixtureRoot, "harbor");
|
||||
|
||||
/**
|
||||
* Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or
|
||||
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
|
||||
* hook to a callable for direct invocation in tests.
|
||||
*/
|
||||
type Callable<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : never;
|
||||
type Callable<T> = T extends (...args: infer A) => infer R
|
||||
? (...args: A) => R
|
||||
: never;
|
||||
|
||||
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
|
||||
isEntry: false,
|
||||
});
|
||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
|
||||
id,
|
||||
undefined,
|
||||
{
|
||||
isEntry: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
@@ -25,73 +34,94 @@ function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
|
||||
/** Parse the JSON payload out of an emitted `export default <json>` module. */
|
||||
function parseModule(code: unknown): unknown {
|
||||
expect(String(code).startsWith('export default ')).toBe(true);
|
||||
return JSON.parse(String(code).slice('export default '.length));
|
||||
expect(String(code).startsWith("export default ")).toBe(true);
|
||||
return JSON.parse(String(code).slice("export default ".length));
|
||||
}
|
||||
|
||||
describe('bgm vite plugin', () => {
|
||||
it('resolves bgm imports to the virtual module', () => {
|
||||
describe("bgm vite plugin", () => {
|
||||
it("resolves bgm imports to the virtual module", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
expect(resolveId(plugin, 'virtual:bgm/packages')).toBe('\0bgm:virtual:bgm/packages');
|
||||
expect(resolveId(plugin, 'virtual:bgm/package/harbor')).toBe('\0bgm:virtual:bgm/package/harbor');
|
||||
expect(resolveId(plugin, 'virtual:bgm/package/nope')).toBe('\0bgm:virtual:bgm/package/nope');
|
||||
expect(resolveId(plugin, 'other')).toBeUndefined();
|
||||
expect(resolveId(plugin, "virtual:bgm/packages")).toBe(
|
||||
"\0bgm:virtual:bgm/packages",
|
||||
);
|
||||
expect(resolveId(plugin, "virtual:bgm/package/harbor")).toBe(
|
||||
"\0bgm:virtual:bgm/package/harbor",
|
||||
);
|
||||
expect(resolveId(plugin, "virtual:bgm/package/nope")).toBe(
|
||||
"\0bgm:virtual:bgm/package/nope",
|
||||
);
|
||||
expect(resolveId(plugin, "other")).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads every package as a JSON module', () => {
|
||||
it("loads every package as a JSON module", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
const packages = parseModule(load(plugin, '\0bgm:virtual:bgm/packages')) as Array<Record<string, any>>;
|
||||
const packages = parseModule(
|
||||
load(plugin, "\0bgm:virtual:bgm/packages"),
|
||||
) as Array<Record<string, any>>;
|
||||
expect(packages).toHaveLength(1);
|
||||
expect(packages[0].meta.id).toBe('harbor');
|
||||
expect(packages[0].parts).toHaveProperty('token#wood');
|
||||
expect(packages[0].meta.id).toBe("harbor");
|
||||
expect(packages[0].parts).toHaveProperty("token#wood");
|
||||
});
|
||||
|
||||
it('loads a package as a JSON module', () => {
|
||||
it("loads a package as a JSON module", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
|
||||
expect(pkg.meta.id).toBe('harbor');
|
||||
expect(pkg.parts).toHaveProperty('token#wood');
|
||||
expect(pkg.surfaces).toHaveProperty('board#harbor');
|
||||
const pkg = parseModule(
|
||||
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
|
||||
) as Record<string, any>;
|
||||
expect(pkg.meta.id).toBe("harbor");
|
||||
expect(pkg.parts).toHaveProperty("token#wood");
|
||||
expect(pkg.surfaces).toHaveProperty("board#harbor");
|
||||
});
|
||||
|
||||
it('serializes maps as plain objects, not Map instances', () => {
|
||||
it("serializes maps as plain objects, not Map instances", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
|
||||
const pkg = parseModule(
|
||||
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
|
||||
) as Record<string, any>;
|
||||
|
||||
// The emitted shape must be JSON-serializable: plain objects keyed by
|
||||
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`).
|
||||
for (const key of ['parts', 'surfaces', 'setups'] as const) {
|
||||
for (const key of ["parts", "surfaces", "setups", "dialogs"] as const) {
|
||||
expect(pkg[key]).not.toBeInstanceOf(Map);
|
||||
expect(pkg[key]).toEqual(expect.any(Object));
|
||||
}
|
||||
|
||||
// Consumers read the collections with Object.values / Object.keys.
|
||||
expect(Object.values(pkg.parts).map((p: any) => p.id).sort()).toEqual(['grain', 'wood']);
|
||||
expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor', 'board#player']);
|
||||
expect(Object.keys(pkg.setups)).toEqual(['game#main']);
|
||||
expect(
|
||||
Object.values(pkg.parts)
|
||||
.map((p: any) => p.id)
|
||||
.sort(),
|
||||
).toEqual(["grain", "wood"]);
|
||||
expect(Object.keys(pkg.surfaces)).toEqual(["board#harbor", "board#player"]);
|
||||
expect(Object.keys(pkg.setups)).toEqual(["game#main"]);
|
||||
});
|
||||
|
||||
it('errors on an unknown package', () => {
|
||||
it("errors on an unknown package", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
expect(() => load(plugin, '\0bgm:virtual:bgm/package/unknown')).toThrow(/not found/);
|
||||
expect(() => load(plugin, "\0bgm:virtual:bgm/package/unknown")).toThrow(
|
||||
/not found/,
|
||||
);
|
||||
});
|
||||
|
||||
it('watches every source file for reloads', () => {
|
||||
it("watches every source file for reloads", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
const watched: string[] = [];
|
||||
const context = { addWatchFile: (file: string) => watched.push(file) };
|
||||
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
|
||||
// the options, so pass a placeholder.
|
||||
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never);
|
||||
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(
|
||||
context,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// Every def file (real + virtual code blocks) is watched so edits
|
||||
// trigger a re-collect. The real markdown source must be watched too:
|
||||
// `defMap.files` only lists virtual code-block files, so without watching
|
||||
// the on-disk `.md` file the dev server would never notice an edit.
|
||||
expect(watched.length).toBeGreaterThan(0);
|
||||
expect(watched.some((f) => f.endsWith('.yaml'))).toBe(true);
|
||||
expect(watched.some((f) => f.endsWith('.csv'))).toBe(true);
|
||||
expect(watched.some((f) => f.endsWith('.md'))).toBe(true);
|
||||
expect(watched.some((f) => f.endsWith(".yaml"))).toBe(true);
|
||||
expect(watched.some((f) => f.endsWith(".csv"))).toBe(true);
|
||||
expect(watched.some((f) => f.endsWith(".md"))).toBe(true);
|
||||
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -23,4 +23,9 @@ export function resolveAssetUrl(url: string, baseUrl?: string): string {
|
||||
/** Route an external asset URL through the proxy so three.js can load it. */
|
||||
export function assetUrl(url: string): string {
|
||||
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 type { TraceResult } from '@tts/shared';
|
||||
@@ -87,6 +87,14 @@ export interface TTSMod {
|
||||
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. */
|
||||
export interface WorkshopItem {
|
||||
id: string;
|
||||
|
||||
@@ -27,17 +27,21 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
console.error('Part viewer error:', error, info.componentStack);
|
||||
}
|
||||
|
||||
private retry = () => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
override render() {
|
||||
if (this.state.error) {
|
||||
return this.props.fallback
|
||||
? this.props.fallback(this.state.error)
|
||||
: <DefaultFallback error={this.state.error} />;
|
||||
: <DefaultFallback error={this.state.error} onRetry={this.retry} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultFallback({ error }: { error: Error }) {
|
||||
function DefaultFallback({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex h-80 flex-col items-center justify-center gap-2 overflow-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 text-center">
|
||||
<p className="text-sm font-medium text-zinc-200">Couldn't render this part</p>
|
||||
@@ -49,6 +53,12 @@ function DefaultFallback({ error }: { error: Error }) {
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-2 rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,5 +36,6 @@ role: setup
|
||||
type: game
|
||||
id: main
|
||||
setup:
|
||||
/deck: harbor:token
|
||||
- path: /deck
|
||||
parts: harbor:token
|
||||
```
|
||||
@@ -24,7 +24,7 @@ const tree = resolveMountTree(
|
||||
new Set(Object.keys(seeded.surfaces)),
|
||||
);
|
||||
|
||||
export const seededPaths = Object.keys(seeded.paths);
|
||||
export const seededPaths = Object.keys(seeded.parts);
|
||||
export const expanded = expandSetupValue(pkg as never, 'harbor:token');
|
||||
export const placementCount = placements.length;
|
||||
export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0');
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The dialog layer — transient UI contexts hosted by the layer-3 shell.
|
||||
*
|
||||
* A dialog is an alternate view of a stack (`docs/bgm/interactions.md` §4):
|
||||
* the deck is "lifted" off the table into the dialog (it stays on its path),
|
||||
* shown in order with an insertion cursor. The cursor *is* the index:
|
||||
* `move(id, path, index)`'s index is discovered by scrolling the visible deck.
|
||||
*
|
||||
* Opening/closing a dialog never issues a command and never touches the game
|
||||
* state — it is pure UI. Only its action buttons issue commands.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Package } from '@tts/bgm';
|
||||
import { useInteractionStore } from './interactions.js';
|
||||
import { useTabletopStore, childrenByPath } from './state.js';
|
||||
|
||||
/**
|
||||
* Render the topmost dialog on the interaction store's stack as an HTML
|
||||
* overlay. Renders nothing when the stack is empty. The dialog shows the
|
||||
* stack at its path in order, with an insertion cursor the player scrolls;
|
||||
* the insert button issues a `move` of the held part (or the top of the
|
||||
* stack) to that path at the cursor.
|
||||
*/
|
||||
export function DialogLayer({ pkg }: { pkg: Package }) {
|
||||
const dialogs = useInteractionStore((s) => s.dialogs);
|
||||
const top = dialogs[dialogs.length - 1];
|
||||
if (!top) return null;
|
||||
|
||||
const dialog = pkg.dialogs.get(top.id);
|
||||
if (!dialog) return null;
|
||||
|
||||
return (
|
||||
<StackDialog
|
||||
key={top.path}
|
||||
pkg={pkg}
|
||||
title={dialog.title}
|
||||
body={dialog.body}
|
||||
path={top.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The stack-inspector dialog: a lifted, ordered view of a path's stack. */
|
||||
function StackDialog({
|
||||
pkg,
|
||||
title,
|
||||
body,
|
||||
path,
|
||||
}: {
|
||||
pkg: Package;
|
||||
title?: string;
|
||||
body?: string;
|
||||
path: string;
|
||||
}) {
|
||||
const parts = useTabletopStore((s) => s.parts);
|
||||
const movePart = useTabletopStore((s) => s.movePart);
|
||||
const held = useInteractionStore((s) => s.held);
|
||||
const drop = useInteractionStore((s) => s.drop);
|
||||
const popDialog = useInteractionStore((s) => s.popDialog);
|
||||
|
||||
// The stack at `path`, in order.
|
||||
const stack = useMemo(() => childrenByPath(parts)[path] ?? [], [parts, path]);
|
||||
const [cursor, setCursor] = useState(stack.length);
|
||||
|
||||
// The part to insert: the held part, or the top of the stack when none is
|
||||
// held (a "look at the top" / reorder use).
|
||||
const source = held?.id;
|
||||
|
||||
const insert = () => {
|
||||
if (!source) return;
|
||||
movePart(source, path, cursor);
|
||||
drop();
|
||||
popDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/60">
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-4 shadow-xl">
|
||||
{title && <h2 className="text-lg font-semibold">{title}</h2>}
|
||||
{body && <p className="mt-1 text-sm text-zinc-400">{body}</p>}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950 p-2">
|
||||
{stack.length === 0 && (
|
||||
<span className="text-xs text-zinc-500">Empty stack</span>
|
||||
)}
|
||||
{stack.map((id, i) => (
|
||||
<div key={id} className="flex items-center gap-1">
|
||||
{i === cursor && <Cursor />}
|
||||
<button
|
||||
onClick={() => setCursor(i)}
|
||||
className="rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-700"
|
||||
title={`Insert before ${label(pkg, id)}`}
|
||||
>
|
||||
{label(pkg, id)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{stack.length > 0 && cursor === stack.length && <Cursor />}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={popDialog}
|
||||
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={insert}
|
||||
disabled={!source}
|
||||
className="rounded-md bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-white disabled:opacity-40"
|
||||
>
|
||||
{source ? `Insert ${label(pkg, source)}` : 'Insert'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small marker between cards showing the insertion point. */
|
||||
function Cursor() {
|
||||
return (
|
||||
<span className="h-6 w-0.5 rounded bg-zinc-100" title="Insertion point" />
|
||||
);
|
||||
}
|
||||
|
||||
/** A short label for a part id (`package:type#id` → `type#id`). */
|
||||
function label(pkg: Package, id: string): string {
|
||||
const key = id.split(':').slice(1).join(':');
|
||||
const part = pkg.parts.get(key);
|
||||
return part?.id ?? key;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
fallbackShape,
|
||||
traceToShape,
|
||||
traceToUvBounds,
|
||||
facingTransform,
|
||||
MM_TO_WORLD,
|
||||
} from './part.js';
|
||||
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||
@@ -24,7 +25,7 @@ export {
|
||||
computeSurfacePlacements,
|
||||
placementKey,
|
||||
} from './state.js';
|
||||
export type { GameState, Placement } from './state.js';
|
||||
export type { GameState, Placement, PartState } from './state.js';
|
||||
|
||||
// Setup seeding.
|
||||
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Package, Setup } from "@tts/bgm";
|
||||
import {
|
||||
interactionsFor,
|
||||
dropPaths,
|
||||
pickPath,
|
||||
partFacings,
|
||||
nextFacing,
|
||||
useInteractionStore,
|
||||
} from "./interactions.js";
|
||||
|
||||
const pkg: Package = {
|
||||
meta: { id: "harbor" },
|
||||
parts: new Map([
|
||||
["card#a", { type: "card", id: "a" }],
|
||||
["card#b", { type: "card", id: "b", facing: ["face", "back"] }],
|
||||
]),
|
||||
surfaces: new Map([
|
||||
[
|
||||
"board#harbor",
|
||||
{
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [
|
||||
{ route: "/deck", x: 0, y: 0, rotation: 0 },
|
||||
{ route: "/discard", x: 40, y: 0, rotation: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
setups: new Map(),
|
||||
dialogs: new Map(),
|
||||
};
|
||||
|
||||
const setup: Setup = {
|
||||
type: "game",
|
||||
id: "main",
|
||||
setup: [],
|
||||
interactions: [
|
||||
{ dialog: "prompt#insert", on: ["/deck"] },
|
||||
{ dialog: "prompt#shuffle" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("interactionsFor", () => {
|
||||
it("returns the interactions whose `on` matches the path, plus any without `on`", () => {
|
||||
expect(interactionsFor(setup, "/deck").map((i) => i.dialog)).toEqual([
|
||||
"prompt#insert",
|
||||
"prompt#shuffle",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes interactions without `on` for any path", () => {
|
||||
expect(interactionsFor(setup, "/discard").map((i) => i.dialog)).toEqual([
|
||||
"prompt#shuffle",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the setup declares no interactions", () => {
|
||||
expect(
|
||||
interactionsFor({ type: "game", id: "main", setup: [] }, "/deck"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropPaths", () => {
|
||||
it("uses the declared `on` paths when interactions exist", () => {
|
||||
expect([...dropPaths(setup, pkg)]).toEqual(["/deck"]);
|
||||
});
|
||||
|
||||
it("falls back to every literal routed path when no interactions are declared", () => {
|
||||
const bare: Setup = { type: "game", id: "main", setup: [] };
|
||||
expect([...dropPaths(bare, pkg)].sort()).toEqual(["/deck", "/discard"]);
|
||||
});
|
||||
|
||||
it("skips :param routes in the fallback, since they need a candidate", () => {
|
||||
const paramPkg: Package = {
|
||||
...pkg,
|
||||
surfaces: new Map([
|
||||
[
|
||||
"board#harbor",
|
||||
{
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [
|
||||
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
|
||||
{ route: "/deck", x: 40, y: 0, rotation: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const bare: Setup = { type: "game", id: "main", setup: [] };
|
||||
expect([...dropPaths(bare, paramPkg)]).toEqual(["/deck"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickPath", () => {
|
||||
it("returns the nearest literal route within the threshold", () => {
|
||||
expect(pickPath(pkg, "board#harbor", 41, 1, 40)).toBe("/discard");
|
||||
});
|
||||
|
||||
it("returns null when nothing is within the threshold", () => {
|
||||
expect(pickPath(pkg, "board#harbor", 100, 100, 40)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unknown surface", () => {
|
||||
expect(pickPath(pkg, "board#nope", 0, 0, 40)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips :param routes, which have no fixed anchor", () => {
|
||||
const paramPkg: Package = {
|
||||
...pkg,
|
||||
surfaces: new Map([
|
||||
[
|
||||
"board#harbor",
|
||||
{
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [
|
||||
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
|
||||
{ route: "/deck", x: 40, y: 0, rotation: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
expect(pickPath(paramPkg, "board#harbor", 100, 100, 40)).toBeNull();
|
||||
expect(pickPath(paramPkg, "board#harbor", 41, 1, 40)).toBe("/deck");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useInteractionStore", () => {
|
||||
beforeEach(() => {
|
||||
useInteractionStore.setState({ held: null, dialogs: [] });
|
||||
});
|
||||
|
||||
it("holds and drops a part", () => {
|
||||
useInteractionStore
|
||||
.getState()
|
||||
.hold({ id: "harbor:card#a", origin: "/deck" });
|
||||
expect(useInteractionStore.getState().held).toEqual({
|
||||
id: "harbor:card#a",
|
||||
origin: "/deck",
|
||||
});
|
||||
useInteractionStore.getState().drop();
|
||||
expect(useInteractionStore.getState().held).toBeNull();
|
||||
});
|
||||
|
||||
it("pushes and pops the dialog stack", () => {
|
||||
const { pushDialog, popDialog } = useInteractionStore.getState();
|
||||
pushDialog({ id: "prompt#insert", path: "/deck" });
|
||||
pushDialog({ id: "prompt#shuffle", path: "/deck" });
|
||||
expect(useInteractionStore.getState().dialogs).toEqual([
|
||||
{ id: "prompt#insert", path: "/deck" },
|
||||
{ id: "prompt#shuffle", path: "/deck" },
|
||||
]);
|
||||
popDialog();
|
||||
expect(useInteractionStore.getState().dialogs).toEqual([
|
||||
{ id: "prompt#insert", path: "/deck" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partFacings / nextFacing", () => {
|
||||
it("defaults to the full set when the part declares none", () => {
|
||||
expect(partFacings(pkg.parts.get("card#a")!)).toEqual([
|
||||
"face",
|
||||
"back",
|
||||
"standing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the declared affordance", () => {
|
||||
expect(partFacings(pkg.parts.get("card#b")!)).toEqual(["face", "back"]);
|
||||
});
|
||||
|
||||
it("cycles forward through the affordance", () => {
|
||||
const part = pkg.parts.get("card#b")!;
|
||||
expect(nextFacing(part, "face")).toBe("back");
|
||||
expect(nextFacing(part, "back")).toBe("face");
|
||||
});
|
||||
|
||||
it("wraps to the first facing after the last", () => {
|
||||
const part = pkg.parts.get("card#b")!;
|
||||
expect(nextFacing(part, "back")).toBe("face");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The free-interaction layer (layer 3 of the layering) — how a player
|
||||
* interacts with a bgm game that has no rules yet: a sandbox.
|
||||
*
|
||||
* The operation set is closed and tiny (`docs/bgm/interactions.md` §1):
|
||||
* `move(id, path, index?)`, `setFacing(id, facing)`, and reorder (a `move`
|
||||
* with an explicit `index`). The game-state store in `state.ts` already
|
||||
* implements `movePart`/`setFacing`; this module adds the *interaction* half:
|
||||
*
|
||||
* - The **held part** — transient "in hand" state that lives outside the store
|
||||
* (a UI-level held part; only the committed drop mutates the store).
|
||||
* - The **dialog stack** — transient UI contexts (the deck pick-up). Opening
|
||||
* or closing a dialog never issues a command and never touches state.
|
||||
*
|
||||
* Both are UI state, hosted by the layer-3 shell, not by the game-state store.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Facing, Interaction, Package, Part, Setup } from '@tts/bgm';
|
||||
import type { Placement } from './state.js';
|
||||
|
||||
/** The transient "in hand" part, held above the board. */
|
||||
export interface HeldPart {
|
||||
/** The part id (`package:type#id`). */
|
||||
id: string;
|
||||
/** The path the part was lifted from, so dropping on nothing returns it. */
|
||||
origin: string;
|
||||
}
|
||||
|
||||
/** A dialog currently open on the dialog stack. */
|
||||
export interface OpenDialog {
|
||||
/** The dialog definition (`type#id`). */
|
||||
id: string;
|
||||
/** The path the dialog was opened on (e.g. the deck being inspected). */
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface InteractionState {
|
||||
/** The part currently held "in hand", or null. */
|
||||
held: HeldPart | null;
|
||||
/** The dialog stack; the last entry is the topmost dialog. */
|
||||
dialogs: OpenDialog[];
|
||||
/** Lift a part into hand. */
|
||||
hold: (part: HeldPart) => void;
|
||||
/** Drop the held part (returns it to its origin). */
|
||||
drop: () => void;
|
||||
/** Push a dialog onto the stack. */
|
||||
pushDialog: (dialog: OpenDialog) => void;
|
||||
/** Pop the topmost dialog. */
|
||||
popDialog: () => void;
|
||||
}
|
||||
|
||||
export const useInteractionStore = create<InteractionState>((set) => ({
|
||||
held: null,
|
||||
dialogs: [],
|
||||
hold: (part) => set({ held: part }),
|
||||
drop: () => set({ held: null }),
|
||||
pushDialog: (dialog) => set((s) => ({ dialogs: [...s.dialogs, dialog] })),
|
||||
popDialog: () => set((s) => ({ dialogs: s.dialogs.slice(0, -1) })),
|
||||
}));
|
||||
|
||||
// --- Pure helpers ---
|
||||
|
||||
/**
|
||||
* Resolve the interaction declarations for a path from a setup: the `dialog`
|
||||
* refs whose `on` matches the path (or that apply to any path). Returns the
|
||||
* matching `Interaction`s in declaration order.
|
||||
*/
|
||||
export function interactionsFor(setup: Setup, path: string): Interaction[] {
|
||||
if (!setup.interactions) return [];
|
||||
return setup.interactions.filter(
|
||||
(i) => !i.on || i.on.some((p) => p === path),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of paths a held part may be dropped on, given a setup's declared
|
||||
* interactions. When the setup declares interactions, only the `on` paths of
|
||||
* those interactions are valid drop targets; otherwise any routed path is.
|
||||
*/
|
||||
export function dropPaths(setup: Setup, pkg: Package): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
if (setup.interactions?.length) {
|
||||
for (const i of setup.interactions) {
|
||||
if (i.on) for (const p of i.on) paths.add(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
// No interactions declared: every routed path is a valid target.
|
||||
for (const surface of pkg.surfaces.values()) {
|
||||
for (const route of surface.layout) {
|
||||
// A literal route is a concrete path; a `:param` route matches many.
|
||||
if (route.route.includes(':')) continue;
|
||||
paths.add(route.route);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest routed path anchor to a point in a surface's local plane
|
||||
* (in mm, origin at the surface's anchor), within `threshold` mm. Returns the
|
||||
* matched path, or null when none is within range. Used to resolve a drop.
|
||||
*/
|
||||
export function pickPath(
|
||||
pkg: Package,
|
||||
surfaceId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
threshold: number,
|
||||
): string | null {
|
||||
const surface = pkg.surfaces.get(surfaceId);
|
||||
if (!surface) return null;
|
||||
let best: { path: string; dist: number } | null = null;
|
||||
for (const route of surface.layout) {
|
||||
// A `:param` route's anchor depends on its candidate; without a candidate
|
||||
// we can't place a part there, so skip it.
|
||||
if (route.route.includes(':')) continue;
|
||||
const dx = x - route.x;
|
||||
const dy = y - route.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist <= threshold && (!best || dist < best.dist)) {
|
||||
best = { path: route.route, dist };
|
||||
}
|
||||
}
|
||||
return best?.path ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical facing affordance of a part: the facings it supports. Defaults
|
||||
* to the full set (`face`, `back`, `standing`) when the part doesn't declare
|
||||
* one. A part's `facing` field (a list of supported facings) is an optional
|
||||
* extra on the `Part` definition.
|
||||
*/
|
||||
export function partFacings(part: Part): Facing[] {
|
||||
const declared = part.facing;
|
||||
const list = Array.isArray(declared) ? (declared as Facing[]) : [];
|
||||
return list.length ? list : ['face', 'back', 'standing'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The next facing in a part's affordance, cycling forward. Used by a click to
|
||||
* cycle `face → back → standing` (or the declared list).
|
||||
*/
|
||||
export function nextFacing(part: Part, current: Facing): Facing {
|
||||
const facings = partFacings(part);
|
||||
const i = facings.indexOf(current);
|
||||
return facings[(i + 1) % facings.length] ?? facings[0]!;
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { serializedToPackage } from './package.js';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serializedToPackage } from "./package.js";
|
||||
|
||||
describe('serializedToPackage', () => {
|
||||
it('converts plain-object maps to Map instances', () => {
|
||||
describe("serializedToPackage", () => {
|
||||
it("converts plain-object maps to Map instances", () => {
|
||||
const serialized = {
|
||||
meta: { id: 'harbor' },
|
||||
parts: { 'token#wood': { type: 'token', id: 'wood' } },
|
||||
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } },
|
||||
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } },
|
||||
meta: { id: "harbor" },
|
||||
parts: { "token#wood": { type: "token", id: "wood" } },
|
||||
surfaces: { "board#harbor": { type: "board", id: "harbor", layout: [] } },
|
||||
setups: { "game#main": { type: "game", id: "main", setup: [] } },
|
||||
dialogs: {
|
||||
"prompt#insert": { type: "prompt", id: "insert", title: "Insert" },
|
||||
},
|
||||
};
|
||||
const pkg = serializedToPackage(serialized);
|
||||
expect(pkg.meta.id).toBe('harbor');
|
||||
expect(pkg.meta.id).toBe("harbor");
|
||||
expect(pkg.parts).toBeInstanceOf(Map);
|
||||
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' });
|
||||
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' });
|
||||
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' });
|
||||
expect(pkg.parts.get("token#wood")).toEqual({ type: "token", id: "wood" });
|
||||
expect(pkg.surfaces.get("board#harbor")).toMatchObject({ type: "board" });
|
||||
expect(pkg.setups.get("game#main")).toMatchObject({ type: "game" });
|
||||
expect(pkg.dialogs.get("prompt#insert")).toMatchObject({ type: "prompt" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* `virtual:bgm/*` get the serialized form; the tabletop components take the
|
||||
* `Package` form.
|
||||
*/
|
||||
import type { Package, SerializedPackage } from '@tts/bgm';
|
||||
import type { Package, SerializedPackage } from "@tts/bgm";
|
||||
|
||||
export function serializedToPackage(serialized: SerializedPackage): Package {
|
||||
return {
|
||||
@@ -12,5 +12,6 @@ export function serializedToPackage(serialized: SerializedPackage): Package {
|
||||
parts: new Map(Object.entries(serialized.parts)),
|
||||
surfaces: new Map(Object.entries(serialized.surfaces)),
|
||||
setups: new Map(Object.entries(serialized.setups)),
|
||||
dialogs: new Map(Object.entries(serialized.dialogs)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fallbackShape,
|
||||
traceToShape,
|
||||
traceToUvBounds,
|
||||
facingTransform,
|
||||
} from './part.js';
|
||||
|
||||
describe('partDimensions', () => {
|
||||
@@ -20,6 +21,25 @@ describe('partDimensions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('facingTransform', () => {
|
||||
const dims = { height: 2, depth: 0.1 };
|
||||
|
||||
it('lays face-up flat on the minZ face', () => {
|
||||
expect(facingTransform('face', dims)).toEqual({ pivot: [0, 0, 0], xRotation: -Math.PI / 2 });
|
||||
});
|
||||
|
||||
it('lays back-down flat on the maxZ face', () => {
|
||||
expect(facingTransform('back', dims)).toEqual({ pivot: [0, 0, 0.1], xRotation: Math.PI / 2 });
|
||||
});
|
||||
|
||||
it('stands on the bottom (minY) edge', () => {
|
||||
expect(facingTransform('standing', dims)).toEqual({
|
||||
pivot: [0, -1, 0.05],
|
||||
xRotation: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spriteUvFromCrop', () => {
|
||||
it('returns full-image UVs without a crop', () => {
|
||||
expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* so they can be unit-tested in a plain node environment (mirroring the web
|
||||
* app's `cardResolution.ts`).
|
||||
*/
|
||||
import type { Part, Crop } from '@tts/bgm';
|
||||
import type { Part, Crop, Facing } from '@tts/bgm';
|
||||
import {
|
||||
rectShape,
|
||||
roundedRectShape,
|
||||
@@ -31,6 +31,31 @@ export function partDimensions(part: Part): { width: number; height: number; dep
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The transform that orients a part for a `facing`, in the part's mesh-local
|
||||
* frame (shape in XY centered at origin, extruded along +Z from `0` to
|
||||
* `depth`). `pivot` is the center of the face/edge that rests on the table and
|
||||
* should land at the anchor; `xRotation` (radians, about the local X axis)
|
||||
* orients the part. Tilt is applied separately about the local Y (long) axis,
|
||||
* so both the facing rotation and tilt spin about the anchor.
|
||||
*/
|
||||
export function facingTransform(
|
||||
facing: Facing,
|
||||
dims: { height: number; depth: number },
|
||||
): { pivot: [number, number, number]; xRotation: number } {
|
||||
switch (facing) {
|
||||
case 'face':
|
||||
// Lay flat, front up, resting on the minZ face.
|
||||
return { pivot: [0, 0, 0], xRotation: -Math.PI / 2 };
|
||||
case 'back':
|
||||
// Lay flat, front down, resting on the maxZ face.
|
||||
return { pivot: [0, 0, dims.depth], xRotation: Math.PI / 2 };
|
||||
case 'standing':
|
||||
// Stand upright on the bottom (minY) edge.
|
||||
return { pivot: [0, -dims.height / 2, dims.depth / 2], xRotation: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UV repeat/offset that selects a single sprite from a sheet, given a crop
|
||||
* `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid
|
||||
|
||||
@@ -86,6 +86,13 @@ export function PartMesh({ part, baseUrl }: { part: Part; baseUrl?: string }) {
|
||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||
|
||||
// Part art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||
// contrast. Mark it sRGB so the GPU decodes it once, correctly. The sprite
|
||||
// clones below inherit this from the source.
|
||||
face.colorSpace = THREE.SRGBColorSpace;
|
||||
back.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
const trace = useTrace(shapeUrl, traceImage);
|
||||
|
||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||
|
||||
@@ -9,10 +9,10 @@ import type { Package } from '@tts/bgm';
|
||||
import { useStacking } from './stacking.js';
|
||||
import type { Placement } from './state.js';
|
||||
import { PartView } from './partView.js';
|
||||
import { MM_TO_WORLD, DEG_TO_RAD } from './part.js';
|
||||
import { MM_TO_WORLD, DEG_TO_RAD, facingTransform, partDimensions } from './part.js';
|
||||
|
||||
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
|
||||
const { route, candidate, piece, index, stackSize } = placement;
|
||||
const { route, candidate, piece, index, stackSize, facing } = placement;
|
||||
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
||||
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
||||
if (!part) return null;
|
||||
@@ -34,11 +34,18 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
||||
const anchorRotation =
|
||||
((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD;
|
||||
|
||||
// The facing pivot is the center of the face/edge that rests on the table
|
||||
// and should land at the anchor. Translate the mesh by the pivot, then apply
|
||||
// the facing rotation and tilt about it, so the part sits on the table.
|
||||
const { width, height, depth } = partDimensions(part);
|
||||
const { pivot, xRotation } = facingTransform(facing, { height, depth });
|
||||
|
||||
return (
|
||||
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
||||
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
||||
<PartView part={part} baseUrl={part.baseUrl} />
|
||||
<group position={[-pivot[0], -pivot[1], -pivot[2]]}>
|
||||
<group rotation={[xRotation, tilt * DEG_TO_RAD, 0]}>
|
||||
<PartView part={part} baseUrl={part.baseUrl} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Package } from '@tts/bgm';
|
||||
import { expandSetupValue, seedFromSetup } from './setup.js';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Facing, Package } from "@tts/bgm";
|
||||
import { expandSetupValue, seedFromSetup } from "./setup.js";
|
||||
|
||||
const pkg: Package = {
|
||||
meta: { id: 'harbor' },
|
||||
meta: { id: "harbor" },
|
||||
parts: new Map([
|
||||
['token#wood', { type: 'token', id: 'wood' }],
|
||||
['token#grain', { type: 'token', id: 'grain' }],
|
||||
['card#fleet', { type: 'card', id: 'fleet' }],
|
||||
["token#wood", { type: "token", id: "wood" }],
|
||||
["token#grain", { type: "token", id: "grain" }],
|
||||
["card#fleet", { type: "card", id: "fleet" }],
|
||||
]),
|
||||
surfaces: new Map([
|
||||
['board#harbor', { type: 'board', id: 'harbor', layout: [] }],
|
||||
['hud#hand', { type: 'hud', id: 'hand', layout: [] }],
|
||||
["board#harbor", { type: "board", id: "harbor", layout: [] }],
|
||||
["hud#hand", { type: "hud", id: "hand", layout: [] }],
|
||||
]),
|
||||
setups: new Map(),
|
||||
dialogs: new Map(),
|
||||
};
|
||||
|
||||
describe('expandSetupValue', () => {
|
||||
it('keeps a full part id', () => {
|
||||
expect(expandSetupValue(pkg, 'harbor:card#fleet')).toEqual(['harbor:card#fleet']);
|
||||
});
|
||||
|
||||
it('expands a bare type to all parts of that type', () => {
|
||||
expect(expandSetupValue(pkg, 'harbor:token')).toEqual(['harbor:token#wood', 'harbor:token#grain']);
|
||||
});
|
||||
|
||||
it('expands each entry of a list', () => {
|
||||
expect(expandSetupValue(pkg, ['harbor:card#fleet', 'harbor:token'])).toEqual([
|
||||
'harbor:card#fleet',
|
||||
'harbor:token#wood',
|
||||
'harbor:token#grain',
|
||||
describe("expandSetupValue", () => {
|
||||
it("keeps a full part id", () => {
|
||||
expect(expandSetupValue(pkg, "harbor:card#fleet")).toEqual([
|
||||
"harbor:card#fleet",
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands a bare type to all parts of that type", () => {
|
||||
expect(expandSetupValue(pkg, "harbor:token")).toEqual([
|
||||
"harbor:token#wood",
|
||||
"harbor:token#grain",
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands each entry of a list", () => {
|
||||
expect(
|
||||
expandSetupValue(pkg, ["harbor:card#fleet", "harbor:token"]),
|
||||
).toEqual(["harbor:card#fleet", "harbor:token#wood", "harbor:token#grain"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedFromSetup', () => {
|
||||
it('enables listed surfaces and places parts', () => {
|
||||
describe("seedFromSetup", () => {
|
||||
it("enables listed surfaces and places parts", () => {
|
||||
const setup = {
|
||||
type: 'game',
|
||||
id: 'main',
|
||||
surfaces: ['board#harbor'],
|
||||
setup: { '/deck': 'harbor:card#fleet' },
|
||||
type: "game",
|
||||
id: "main",
|
||||
surfaces: ["board#harbor"],
|
||||
setup: [{ path: "/deck", parts: "harbor:card#fleet" }],
|
||||
};
|
||||
const state = seedFromSetup(pkg, setup);
|
||||
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
||||
expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] });
|
||||
expect(state.surfaces).toEqual({ "board#harbor": true });
|
||||
expect(state.parts).toEqual({
|
||||
"harbor:card#fleet": { path: "/deck", index: 0, facing: "face" },
|
||||
});
|
||||
});
|
||||
|
||||
it('enables all surfaces when omitted', () => {
|
||||
const setup = { type: 'game', id: 'main', setup: {} };
|
||||
it("enables all surfaces when omitted", () => {
|
||||
const setup = { type: "game", id: "main", setup: [] };
|
||||
const state = seedFromSetup(pkg, setup);
|
||||
expect(state.surfaces).toEqual({ 'board#harbor': true, 'hud#hand': true });
|
||||
expect(state.surfaces).toEqual({ "board#harbor": true, "hud#hand": true });
|
||||
});
|
||||
});
|
||||
|
||||
it("applies placements in order, last path wins", () => {
|
||||
// `harbor:card#fleet` is placed on /deck first, then moved to /hand. Each
|
||||
// path's indices stay contiguous 0..n-1 so stacking stays valid.
|
||||
const setup = {
|
||||
type: "game",
|
||||
id: "main",
|
||||
setup: [
|
||||
{ path: "/deck", parts: ["harbor:card#fleet", "harbor:token#wood"] },
|
||||
{ path: "/hand", parts: "harbor:card#fleet" },
|
||||
],
|
||||
};
|
||||
const state = seedFromSetup(pkg, setup);
|
||||
expect(state.parts["harbor:card#fleet"]).toEqual({
|
||||
path: "/hand",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
expect(state.parts["harbor:token#wood"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the facing from the placement, defaulting to face", () => {
|
||||
const setup = {
|
||||
type: "game",
|
||||
id: "main",
|
||||
setup: [
|
||||
{ path: "/deck", parts: "harbor:card#fleet", facing: "back" as Facing },
|
||||
{
|
||||
path: "/table",
|
||||
parts: "harbor:token#wood",
|
||||
facing: "standing" as Facing,
|
||||
},
|
||||
{ path: "/hand", parts: "harbor:token#grain" },
|
||||
],
|
||||
};
|
||||
const state = seedFromSetup(pkg, setup);
|
||||
expect(state.parts["harbor:card#fleet"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "back",
|
||||
});
|
||||
expect(state.parts["harbor:token#wood"]).toEqual({
|
||||
path: "/table",
|
||||
index: 0,
|
||||
facing: "standing",
|
||||
});
|
||||
// No `facing` on the placement defaults to `face`.
|
||||
expect(state.parts["harbor:token#grain"]).toEqual({
|
||||
path: "/hand",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* `type` (no id) expands to all parts of that type during initialization.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import type { Package, Setup } from '@tts/bgm';
|
||||
import { useTabletopStore } from './state.js';
|
||||
import type { Package, Setup, SetupValue } from '@tts/bgm';
|
||||
import { useTabletopStore, type PartState } from './state.js';
|
||||
|
||||
/**
|
||||
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
||||
@@ -15,7 +15,7 @@ import { useTabletopStore } from './state.js';
|
||||
*/
|
||||
export function expandSetupValue(
|
||||
pkg: Package,
|
||||
value: string | string[],
|
||||
value: SetupValue,
|
||||
): string[] {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const out: string[] = [];
|
||||
@@ -39,7 +39,7 @@ export function expandSetupValue(
|
||||
/** Seed the store from a setup. Returns the resulting game state. */
|
||||
export function seedFromSetup(pkg: Package, setup: Setup): {
|
||||
surfaces: Record<string, boolean>;
|
||||
paths: Record<string, string[]>;
|
||||
parts: Record<string, PartState>;
|
||||
} {
|
||||
const surfaces: Record<string, boolean> = {};
|
||||
if (setup.surfaces) {
|
||||
@@ -48,11 +48,26 @@ export function seedFromSetup(pkg: Package, setup: Setup): {
|
||||
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
||||
}
|
||||
|
||||
const paths: Record<string, string[]> = {};
|
||||
for (const [path, value] of Object.entries(setup.setup)) {
|
||||
paths[path] = expandSetupValue(pkg, value);
|
||||
// Apply placements in order: each moves its parts to its path. A part listed
|
||||
// in a later placement ends up on that placement's path. Then index each
|
||||
// path's parts contiguously, in placement order, so `index` is always a
|
||||
// valid position within its path's stack.
|
||||
const parts: Record<string, PartState> = {};
|
||||
for (const placement of setup.setup) {
|
||||
for (const id of expandSetupValue(pkg, placement.parts)) {
|
||||
parts[id] = { path: placement.path, index: 0, facing: placement.facing ?? 'face' };
|
||||
}
|
||||
}
|
||||
return { surfaces, paths };
|
||||
const byPath: Record<string, string[]> = {};
|
||||
for (const [id, ps] of Object.entries(parts)) {
|
||||
(byPath[ps.path] ??= []).push(id);
|
||||
}
|
||||
for (const [path, ids] of Object.entries(byPath)) {
|
||||
ids.forEach((id, index) => {
|
||||
parts[id] = { ...parts[id]!, index };
|
||||
});
|
||||
}
|
||||
return { surfaces, parts };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,131 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js";
|
||||
|
||||
describe('parsePath', () => {
|
||||
it('measures a straight line', () => {
|
||||
const path = parsePath('M 0 0 L 10 0');
|
||||
describe("parsePath", () => {
|
||||
it("measures a straight line", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
expect(path.length).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it('measures a cubic curve', () => {
|
||||
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
|
||||
it("measures a cubic curve", () => {
|
||||
const path = parsePath("M 0 0 C 20 -20 40 -20 60 0");
|
||||
// Longer than the chord (60) but finite.
|
||||
expect(path.length).toBeGreaterThan(60);
|
||||
expect(path.length).toBeLessThan(80);
|
||||
});
|
||||
|
||||
it('handles relative commands', () => {
|
||||
const path = parsePath('m 0 0 l 10 0 l 0 10');
|
||||
it("handles relative commands", () => {
|
||||
const path = parsePath("m 0 0 l 10 0 l 0 10");
|
||||
expect(path.length).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('supports h/v/z', () => {
|
||||
const path = parsePath('M 0 0 H 10 V 10 Z');
|
||||
it("supports h/v/z", () => {
|
||||
const path = parsePath("M 0 0 H 10 V 10 Z");
|
||||
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
|
||||
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
|
||||
});
|
||||
|
||||
it("supports quadratic curves (Q)", () => {
|
||||
const path = parsePath("M 0 0 Q 50 50 100 0");
|
||||
// Longer than the chord (100) but finite.
|
||||
expect(path.length).toBeGreaterThan(100);
|
||||
expect(path.length).toBeLessThan(120);
|
||||
// The curve passes through the midpoint of the control point.
|
||||
const mid = pointAt(path, path.length / 2);
|
||||
expect(mid.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports smooth quadratic continuation (T)", () => {
|
||||
const path = parsePath("M 0 0 Q 50 50 100 0 T 200 0");
|
||||
// Two quadratic segments; the second reflects the first's control point.
|
||||
expect(path.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("supports smooth cubic continuation (S)", () => {
|
||||
const path = parsePath("M 0 0 C 25 50 75 50 100 0 S 175 -50 200 0");
|
||||
expect(path.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("supports elliptical arcs (A)", () => {
|
||||
// A semicircle of radius 50: length ≈ π * 50.
|
||||
const path = parsePath("M 0 0 A 50 50 0 0 1 100 0");
|
||||
expect(path.length).toBeCloseTo(Math.PI * 50, 0);
|
||||
});
|
||||
|
||||
it("supports relative variants of each command", () => {
|
||||
const path = parsePath("m 0 0 q 50 50 100 0 t 100 0");
|
||||
expect(path.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("throws on an unsupported command", () => {
|
||||
expect(() => parsePath("M 0 0 R 10 10")).toThrow(
|
||||
/Unsupported SVG path command/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointAt', () => {
|
||||
it('returns the start at distance 0 and end at full length', () => {
|
||||
const path = parsePath('M 0 0 L 10 0');
|
||||
describe("pointAt", () => {
|
||||
it("returns the start at distance 0 and end at full length", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
||||
const end = pointAt(path, path.length);
|
||||
expect(end.x).toBeCloseTo(10);
|
||||
expect(end.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('interpolates along the path', () => {
|
||||
const path = parsePath('M 0 0 L 10 0');
|
||||
it("interpolates along the path", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
const mid = pointAt(path, 5);
|
||||
expect(mid.x).toBeCloseTo(5);
|
||||
expect(mid.angle).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('returns the tangent angle in degrees', () => {
|
||||
const path = parsePath('M 0 0 L 10 10');
|
||||
it("returns the tangent angle in degrees", () => {
|
||||
const path = parsePath("M 0 0 L 10 10");
|
||||
expect(pointAt(path, 5).angle).toBeCloseTo(45);
|
||||
const down = parsePath('M 0 0 L 0 10');
|
||||
const down = parsePath("M 0 0 L 0 10");
|
||||
expect(pointAt(down, 5).angle).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it('returns the tangent angle at the start of the path', () => {
|
||||
const path = parsePath('M 0 0 L 10 10');
|
||||
it("returns the tangent angle at the start of the path", () => {
|
||||
const path = parsePath("M 0 0 L 10 10");
|
||||
expect(pointAt(path, 0).angle).toBeCloseTo(45);
|
||||
});
|
||||
|
||||
it("clamps distance to the path length", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
expect(pointAt(path, 999).x).toBeCloseTo(10);
|
||||
expect(pointAt(path, -5).x).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("returns the origin for an empty path", () => {
|
||||
expect(pointAt({ points: [], length: 0 }, 5)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the single point for a degenerate path", () => {
|
||||
const path = parsePath("M 5 5");
|
||||
expect(pointAt(path, 0)).toEqual({ x: 5, y: 5, angle: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stackingOffset', () => {
|
||||
it('defaults to a 1° tilt without a curve', () => {
|
||||
expect(stackingOffset(undefined, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||
describe("stackingOffset", () => {
|
||||
it("defaults to a 1° tilt without a curve", () => {
|
||||
expect(stackingOffset(undefined, 0, 3)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
tilt: 1,
|
||||
});
|
||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
tilt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('spreads parts evenly along a straight curve', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
|
||||
it("spreads parts evenly along a straight curve", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3);
|
||||
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
||||
expect(offset.x).toBeCloseTo(50);
|
||||
expect(offset.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('aligns to center', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
|
||||
it("aligns to center", () => {
|
||||
const offset = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", align: "center" },
|
||||
0,
|
||||
3,
|
||||
);
|
||||
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
||||
expect(offset.x).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('aligns to end', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
|
||||
it("aligns to end", () => {
|
||||
const offset = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", align: "end" },
|
||||
2,
|
||||
3,
|
||||
);
|
||||
// start = 100 - 100 = 0; part 2 at 100.
|
||||
expect(offset.x).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it('respects a positive limit (first n)', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4);
|
||||
it("respects a positive limit (first n)", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: 2 }, 2, 4);
|
||||
// Part 2 is beyond the first 2 shown -> not placed.
|
||||
expect(offset).toBe(NO_OFFSET);
|
||||
});
|
||||
|
||||
it('respects a negative limit (last n)', () => {
|
||||
it("respects a negative limit (last n)", () => {
|
||||
// Last 2 of 4 are indices 2,3. Part 2 is the first shown.
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4);
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: -2 }, 2, 4);
|
||||
expect(offset.x).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('uses steps to densify the curve', () => {
|
||||
it("uses steps to densify the curve", () => {
|
||||
// steps=4, 3 parts -> step = 100 / max(4, 2) = 25.
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", steps: 4 }, 1, 3);
|
||||
expect(offset.x).toBeCloseTo(25);
|
||||
});
|
||||
|
||||
it('tilts every part the same amount without a curve', () => {
|
||||
it("tilts every part the same amount without a curve", () => {
|
||||
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
||||
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
||||
});
|
||||
|
||||
it('tilts every part the same amount along the curve', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
|
||||
it("tilts every part the same amount along the curve", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3);
|
||||
expect(offset.x).toBeCloseTo(50);
|
||||
expect(offset.tilt).toBeCloseTo(0.1);
|
||||
});
|
||||
|
||||
it('tilts only the shown parts', () => {
|
||||
it("tilts only the shown parts", () => {
|
||||
// limit 2 shows indices 0,1; index 2 is dropped.
|
||||
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET);
|
||||
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
|
||||
});
|
||||
|
||||
it('ramps z from zStart to zEnd across the curve', () => {
|
||||
it("ramps z from zStart to zEnd across the curve", () => {
|
||||
// 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40.
|
||||
const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3);
|
||||
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
|
||||
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
|
||||
const first = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||
0,
|
||||
3,
|
||||
);
|
||||
const mid = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||
1,
|
||||
3,
|
||||
);
|
||||
const last = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||
2,
|
||||
3,
|
||||
);
|
||||
expect(first.z).toBeCloseTo(0);
|
||||
expect(mid.z).toBeCloseTo(20);
|
||||
expect(last.z).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
it('returns no offset for an empty stack', () => {
|
||||
it("returns no offset for an empty stack", () => {
|
||||
expect(stackingOffset(undefined, 0, 0)).toBe(NO_OFFSET);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies only the default tilt when a curve has zero length", () => {
|
||||
// A degenerate curve (a single point) has length 0, so no horizontal
|
||||
// offset applies, but the default 1° tilt still does.
|
||||
expect(stackingOffset({ curve: "M 5 5" }, 0, 3)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
tilt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("places a single part at the start of the curve", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 0, 1);
|
||||
// span = max(steps=1, 0) = 1; step = 100; part 0 at 0.
|
||||
expect(offset.x).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Stacking — the format's positioning process (`bgm-format.md` §4).
|
||||
* Stacking — the format's positioning process (`docs/bgm/format.md` §4).
|
||||
*
|
||||
* Given a route's `stacking` strategy and a piece's position in its path's
|
||||
* stack, compute the offset/rotation to apply. Parts are spread along an SVG
|
||||
|
||||
@@ -1,166 +1,329 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Package, Surface } from '@tts/bgm';
|
||||
import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Facing, Package, Surface } from "@tts/bgm";
|
||||
import {
|
||||
matchRoute,
|
||||
childrenByPath,
|
||||
computeSurfacePlacements,
|
||||
computeRenderState,
|
||||
placementKey,
|
||||
useTabletopStore,
|
||||
} from "./state.js";
|
||||
|
||||
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
||||
return {
|
||||
type: 'board',
|
||||
id: 'harbor',
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const pkg: Package = {
|
||||
meta: { id: 'harbor' },
|
||||
meta: { id: "harbor" },
|
||||
parts: new Map(),
|
||||
surfaces: new Map([
|
||||
['board#harbor', makeSurface()],
|
||||
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
|
||||
["board#harbor", makeSurface()],
|
||||
["hud#hand", makeSurface({ type: "hud", id: "hand" })],
|
||||
]),
|
||||
setups: new Map(),
|
||||
dialogs: new Map(),
|
||||
};
|
||||
|
||||
describe('matchRoute', () => {
|
||||
it('matches a literal path', () => {
|
||||
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
|
||||
expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined });
|
||||
expect(matchRoute(route, '/other')).toBeNull();
|
||||
describe("matchRoute", () => {
|
||||
it("matches a literal path", () => {
|
||||
const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
|
||||
expect(matchRoute(route, "/deck")).toEqual({ candidate: undefined });
|
||||
expect(matchRoute(route, "/other")).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a :param against a candidate', () => {
|
||||
it("matches a :param against a candidate", () => {
|
||||
const route = {
|
||||
route: '/dock/:seat',
|
||||
route: "/dock/:seat",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
candidates: [
|
||||
{ seat: '0', x: 40, y: 0 },
|
||||
{ seat: '1', x: 40, y: 20 },
|
||||
{ seat: "0", x: 40, y: 0 },
|
||||
{ seat: "1", x: 40, y: 20 },
|
||||
],
|
||||
};
|
||||
expect(matchRoute(route, '/dock/1')).toEqual({ candidate: { seat: '1', x: 40, y: 20 } });
|
||||
expect(matchRoute(route, "/dock/1")).toEqual({
|
||||
candidate: { seat: "1", x: 40, y: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when no candidate matches the param', () => {
|
||||
it("fails when no candidate matches the param", () => {
|
||||
const route = {
|
||||
route: '/dock/:seat',
|
||||
route: "/dock/:seat",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
candidates: [{ seat: '0', x: 40, y: 0 }],
|
||||
candidates: [{ seat: "0", x: 40, y: 0 }],
|
||||
};
|
||||
expect(matchRoute(route, '/dock/9')).toBeNull();
|
||||
expect(matchRoute(route, "/dock/9")).toBeNull();
|
||||
});
|
||||
|
||||
it('fails on length mismatch', () => {
|
||||
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
|
||||
expect(matchRoute(route, '/deck/extra')).toBeNull();
|
||||
it("fails on length mismatch", () => {
|
||||
const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
|
||||
expect(matchRoute(route, "/deck/extra")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSurfacePlacements', () => {
|
||||
it('places parts on a matching route with index and stackSize', () => {
|
||||
describe("childrenByPath", () => {
|
||||
it("groups parts by path, ordered by index", () => {
|
||||
const parts: Record<
|
||||
string,
|
||||
{ path: string; index: number; facing: Facing }
|
||||
> = {
|
||||
"harbor:card#a": { path: "/deck", index: 1, facing: "face" },
|
||||
"harbor:card#b": { path: "/deck", index: 0, facing: "back" },
|
||||
"harbor:card#c": { path: "/community/0", index: 0, facing: "standing" },
|
||||
};
|
||||
expect(childrenByPath(parts)).toEqual({
|
||||
"/deck": ["harbor:card#b", "harbor:card#a"],
|
||||
"/community/0": ["harbor:card#c"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSurfacePlacements", () => {
|
||||
it("places parts on a matching route with index, stackSize, and facing", () => {
|
||||
const surface = makeSurface({
|
||||
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
|
||||
layout: [{ route: "/deck", x: -100, y: 0, rotation: 0 }],
|
||||
});
|
||||
const placements = computeSurfacePlacements(surface, {
|
||||
'/deck': ['harbor:card#a', 'harbor:card#b'],
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
|
||||
"harbor:card#b": { path: "/deck", index: 1, facing: "back" as Facing },
|
||||
});
|
||||
expect(placements).toHaveLength(2);
|
||||
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 });
|
||||
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 });
|
||||
expect(placements[0]).toMatchObject({
|
||||
piece: "harbor:card#a",
|
||||
index: 0,
|
||||
stackSize: 2,
|
||||
facing: "face",
|
||||
});
|
||||
expect(placements[1]).toMatchObject({
|
||||
piece: "harbor:card#b",
|
||||
index: 1,
|
||||
stackSize: 2,
|
||||
facing: "back",
|
||||
});
|
||||
});
|
||||
|
||||
it('drops parts with no matching route', () => {
|
||||
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
||||
it("drops parts with no matching route", () => {
|
||||
const surface = makeSurface({
|
||||
layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }],
|
||||
});
|
||||
const placements = computeSurfacePlacements(surface, {
|
||||
'/deck': ['harbor:card#a'],
|
||||
'/elsewhere': ['harbor:card#b'],
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
|
||||
"harbor:card#b": {
|
||||
path: "/elsewhere",
|
||||
index: 0,
|
||||
facing: "face" as Facing,
|
||||
},
|
||||
});
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(placements[0]!.piece).toBe('harbor:card#a');
|
||||
expect(placements[0]!.piece).toBe("harbor:card#a");
|
||||
});
|
||||
|
||||
it('uses the candidate anchor for a :param route', () => {
|
||||
it("uses the candidate anchor for a :param route", () => {
|
||||
const surface = makeSurface({
|
||||
layout: [
|
||||
{
|
||||
route: '/dock/:seat',
|
||||
route: "/dock/:seat",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
candidates: [{ seat: '0', x: 40, y: 5, rotation: 1 }],
|
||||
candidates: [{ seat: "0", x: 40, y: 5, rotation: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
||||
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
|
||||
});
|
||||
|
||||
it('keeps the candidate stacking alongside its anchor', () => {
|
||||
const surface = makeSurface({
|
||||
layout: [
|
||||
{
|
||||
route: '/dock/:seat',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
candidates: [{ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
||||
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } });
|
||||
});
|
||||
|
||||
it('keeps the same piece on two paths as distinct placements', () => {
|
||||
const surface = makeSurface({
|
||||
layout: [
|
||||
{ route: '/deck', x: 0, y: 0, rotation: 0 },
|
||||
{ route: '/community/:slot', x: 0, y: 0, rotation: 0 },
|
||||
],
|
||||
});
|
||||
// The deck expands to every card (including `as`); the flop also places `as`.
|
||||
const placements = computeSurfacePlacements(surface, {
|
||||
'/deck': ['poker:card#as', 'poker:card#kh'],
|
||||
'/community/0': ['poker:card#as'],
|
||||
"harbor:boat#fleet": {
|
||||
path: "/dock/0",
|
||||
index: 0,
|
||||
facing: "face" as Facing,
|
||||
},
|
||||
});
|
||||
expect(placements).toHaveLength(3);
|
||||
const deck = placements.filter((p) => p.path === '/deck');
|
||||
const flop = placements.filter((p) => p.path === '/community/0');
|
||||
expect(deck).toHaveLength(2);
|
||||
expect(flop).toHaveLength(1);
|
||||
// The same piece on two paths yields distinct placement keys.
|
||||
expect(placementKey(deck[0]!)).not.toBe(placementKey(flop[0]!));
|
||||
expect(placements[0]!.candidate).toEqual({
|
||||
seat: "0",
|
||||
x: 40,
|
||||
y: 5,
|
||||
rotation: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the candidate stacking alongside its anchor", () => {
|
||||
const surface = makeSurface({
|
||||
layout: [
|
||||
{
|
||||
route: "/dock/:seat",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
candidates: [{ seat: "0", x: 40, y: 5, stacking: { tilt: 2 } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const placements = computeSurfacePlacements(surface, {
|
||||
"harbor:boat#fleet": {
|
||||
path: "/dock/0",
|
||||
index: 0,
|
||||
facing: "face" as Facing,
|
||||
},
|
||||
});
|
||||
expect(placements[0]!.candidate).toEqual({
|
||||
seat: "0",
|
||||
x: 40,
|
||||
y: 5,
|
||||
stacking: { tilt: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("orders a path by index regardless of insertion order", () => {
|
||||
const surface = makeSurface({
|
||||
layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }],
|
||||
});
|
||||
const placements = computeSurfacePlacements(surface, {
|
||||
"harbor:card#b": { path: "/deck", index: 1, facing: "face" as Facing },
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
|
||||
});
|
||||
expect(placements.map((p) => p.piece)).toEqual([
|
||||
"harbor:card#a",
|
||||
"harbor:card#b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRenderState', () => {
|
||||
it('only includes enabled surfaces', () => {
|
||||
describe("computeRenderState", () => {
|
||||
it("only includes enabled surfaces", () => {
|
||||
const state = {
|
||||
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
||||
paths: { '/deck': ['harbor:card#a'] },
|
||||
surfaces: { "board#harbor": true, "hud#hand": false },
|
||||
parts: {
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
|
||||
},
|
||||
};
|
||||
pkg.surfaces.set(
|
||||
'board#harbor',
|
||||
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }),
|
||||
"board#harbor",
|
||||
makeSurface({ layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }] }),
|
||||
);
|
||||
const placements = computeRenderState(pkg, state);
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(placements[0]!.surface).toBe('board#harbor');
|
||||
expect(placements[0]!.surface).toBe("board#harbor");
|
||||
});
|
||||
});
|
||||
|
||||
describe('placementKey', () => {
|
||||
it('is unique per surface, path, and piece', () => {
|
||||
const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never;
|
||||
const b = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#b' } as never;
|
||||
const c = { surface: 'hud#hand', path: '/deck', piece: 'harbor:card#a' } as never;
|
||||
// The same piece on two paths of the same surface is a distinct placement.
|
||||
const d = { surface: 'board#harbor', path: '/community/0', piece: 'harbor:card#a' } as never;
|
||||
describe("useTabletopStore", () => {
|
||||
beforeEach(() => {
|
||||
useTabletopStore.setState({ surfaces: {}, parts: {} });
|
||||
});
|
||||
|
||||
it("seeds surfaces and parts", () => {
|
||||
useTabletopStore
|
||||
.getState()
|
||||
.seed({ surfaces: { "board#harbor": true }, parts: {} });
|
||||
expect(useTabletopStore.getState().surfaces).toEqual({
|
||||
"board#harbor": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("enables and disables a surface", () => {
|
||||
useTabletopStore.getState().enableSurface("board#harbor");
|
||||
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(true);
|
||||
useTabletopStore.getState().disableSurface("board#harbor");
|
||||
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(false);
|
||||
});
|
||||
|
||||
it("setPart patches an existing part and ignores an unknown id", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
});
|
||||
useTabletopStore.getState().setPart("harbor:card#a", { facing: "back" });
|
||||
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "back",
|
||||
});
|
||||
useTabletopStore.getState().setPart("harbor:card#nope", { facing: "back" });
|
||||
expect(
|
||||
useTabletopStore.getState().parts["harbor:card#nope"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("movePart reindexes source and destination, inserting at index", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
|
||||
"harbor:card#c": { path: "/deck", index: 2, facing: "face" },
|
||||
});
|
||||
// Move the top card to the bottom (index 0).
|
||||
useTabletopStore.getState().movePart("harbor:card#c", "/deck", 0);
|
||||
const parts = useTabletopStore.getState().parts;
|
||||
expect(parts["harbor:card#c"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#a"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 1,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#b"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 2,
|
||||
facing: "face",
|
||||
});
|
||||
});
|
||||
|
||||
it("movePart moves between paths, closing the source gap", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
|
||||
"harbor:card#c": { path: "/discard", index: 0, facing: "face" },
|
||||
});
|
||||
useTabletopStore.getState().movePart("harbor:card#a", "/discard", 0);
|
||||
const parts = useTabletopStore.getState().parts;
|
||||
expect(parts["harbor:card#a"]).toEqual({
|
||||
path: "/discard",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#c"]).toEqual({
|
||||
path: "/discard",
|
||||
index: 1,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#b"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
});
|
||||
|
||||
it("movePart clamps index and ignores an unknown id", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
});
|
||||
useTabletopStore.getState().movePart("harbor:card#a", "/deck", 99);
|
||||
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
useTabletopStore.getState().movePart("harbor:card#nope", "/deck", 0);
|
||||
expect(
|
||||
useTabletopStore.getState().parts["harbor:card#nope"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placementKey", () => {
|
||||
it("is unique per surface and piece", () => {
|
||||
const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;
|
||||
const b = { surface: "board#harbor", piece: "harbor:card#b" } as never;
|
||||
const c = { surface: "hud#hand", piece: "harbor:card#a" } as never;
|
||||
expect(placementKey(a)).not.toBe(placementKey(b));
|
||||
expect(placementKey(a)).not.toBe(placementKey(c));
|
||||
expect(placementKey(a)).not.toBe(placementKey(d));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
/**
|
||||
* Source-of-truth game state and the derived render state.
|
||||
*
|
||||
* The store holds the enabled surfaces and the path -> part placement map
|
||||
* (`bgm-tabletop.md` §2). The derived render state is computed from the game
|
||||
* The store holds the enabled surfaces and a per-part placement map
|
||||
* (`bgm-tabletop.md` §2). Each part on the board is keyed by its id
|
||||
* (`package:type#id`) and records which path it's on, its index in that path's
|
||||
* stack, and which face is up. A path's ordered children (for stacking) are
|
||||
* derived from this map. The derived render state is computed from the game
|
||||
* state plus a package's surface routes: a stable list of placements, one per
|
||||
* (surface, piece) pair, keyed for rendering.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import type { Candidate, Package, Route, Surface } from '@tts/bgm';
|
||||
import type { Candidate, Facing, Package, Route, Surface } from '@tts/bgm';
|
||||
|
||||
/** The placement state of a single part on the board. */
|
||||
export interface PartState {
|
||||
/** The path key this part is on. */
|
||||
path: string;
|
||||
/** The part's position in its path's stack. */
|
||||
index: number;
|
||||
/** How the part is oriented on the board. */
|
||||
facing: Facing;
|
||||
}
|
||||
|
||||
/** Source-of-truth game state. */
|
||||
export interface GameState {
|
||||
/** Enabled per surface id (`type#id`). */
|
||||
surfaces: Record<string, boolean>;
|
||||
/** Path -> part list (`package:type#id`). */
|
||||
paths: Record<string, string[]>;
|
||||
/** Part id (`package:type#id`) -> placement state. */
|
||||
parts: Record<string, PartState>;
|
||||
}
|
||||
|
||||
/** A single placed piece on a surface, ready for rendering. */
|
||||
@@ -34,26 +47,59 @@ export interface Placement {
|
||||
index: number;
|
||||
/** The number of pieces on the path. */
|
||||
stackSize: number;
|
||||
/** How the piece is oriented on the board. */
|
||||
facing: Facing;
|
||||
}
|
||||
|
||||
interface TabletopState extends GameState {
|
||||
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
||||
setPaths: (paths: Record<string, string[]>) => void;
|
||||
setParts: (parts: Record<string, PartState>) => void;
|
||||
seed: (state: GameState) => void;
|
||||
enableSurface: (id: string) => void;
|
||||
disableSurface: (id: string) => void;
|
||||
setPath: (path: string, parts: string[]) => void;
|
||||
setPart: (id: string, patch: Partial<PartState>) => void;
|
||||
movePart: (id: string, path: string, index: number) => void;
|
||||
}
|
||||
|
||||
export const useTabletopStore = create<TabletopState>((set) => ({
|
||||
surfaces: {},
|
||||
paths: {},
|
||||
parts: {},
|
||||
setSurfaces: (surfaces) => set({ surfaces }),
|
||||
setPaths: (paths) => set({ paths }),
|
||||
setParts: (parts) => set({ parts }),
|
||||
seed: (state) => set(state),
|
||||
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
||||
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
||||
setPath: (path, parts) => set((s) => ({ paths: { ...s.paths, [path]: parts } })),
|
||||
setPart: (id, patch) =>
|
||||
set((s) => {
|
||||
const cur = s.parts[id];
|
||||
if (!cur) return s;
|
||||
return { parts: { ...s.parts, [id]: { ...cur, ...patch } } };
|
||||
}),
|
||||
movePart: (id, path, index) =>
|
||||
set((s) => {
|
||||
const cur = s.parts[id];
|
||||
if (!cur) return s;
|
||||
const parts = { ...s.parts };
|
||||
// Siblings on the source path, in order, excluding the moved part.
|
||||
const srcIds = Object.keys(parts)
|
||||
.filter((pid) => pid !== id && parts[pid]!.path === cur.path)
|
||||
.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
||||
// Siblings on the destination path, in order, excluding the moved part.
|
||||
const dstIds = Object.keys(parts)
|
||||
.filter((pid) => pid !== id && parts[pid]!.path === path)
|
||||
.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
||||
// Reindex the source path so the gap closes.
|
||||
srcIds.forEach((pid, i) => {
|
||||
parts[pid] = { ...parts[pid]!, index: i };
|
||||
});
|
||||
// Reindex the destination path with the moved part inserted at `index`.
|
||||
const clamped = Math.max(0, Math.min(index, dstIds.length));
|
||||
dstIds.forEach((pid, i) => {
|
||||
parts[pid] = { ...parts[pid]!, index: i >= clamped ? i + 1 : i };
|
||||
});
|
||||
parts[id] = { ...cur, path, index: clamped };
|
||||
return { parts };
|
||||
}),
|
||||
}));
|
||||
|
||||
// --- Route matching ---
|
||||
@@ -87,24 +133,43 @@ export function matchRoute(route: Route, path: string): { candidate?: Candidate
|
||||
return { candidate: undefined };
|
||||
}
|
||||
|
||||
/** Compute the placements for a single surface from the game state's paths. */
|
||||
export function computeSurfacePlacements(surface: Surface, paths: Record<string, string[]>): Placement[] {
|
||||
/**
|
||||
* Derive each path's ordered children from the parts map. A path's children are
|
||||
* its part ids sorted by `index`, used for stacking (`stackSize` and per-piece
|
||||
* `index`).
|
||||
*/
|
||||
export function childrenByPath(parts: Record<string, PartState>): Record<string, string[]> {
|
||||
const children: Record<string, string[]> = {};
|
||||
for (const [id, ps] of Object.entries(parts)) {
|
||||
(children[ps.path] ??= []).push(id);
|
||||
}
|
||||
for (const list of Object.values(children)) {
|
||||
list.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/** Compute the placements for a single surface from the game state's parts. */
|
||||
export function computeSurfacePlacements(surface: Surface, parts: Record<string, PartState>): Placement[] {
|
||||
const placements: Placement[] = [];
|
||||
const surfaceId = `${surface.type}#${surface.id}`;
|
||||
for (const [path, parts] of Object.entries(paths)) {
|
||||
const children = childrenByPath(parts);
|
||||
for (const [path, ids] of Object.entries(children)) {
|
||||
const route = surface.layout.find((r) => matchRoute(r, path));
|
||||
if (!route) continue;
|
||||
const match = matchRoute(route, path)!;
|
||||
const stackSize = parts.length;
|
||||
for (const piece of parts) {
|
||||
const stackSize = ids.length;
|
||||
for (const piece of ids) {
|
||||
const ps = parts[piece]!;
|
||||
placements.push({
|
||||
surface: surfaceId,
|
||||
path,
|
||||
route,
|
||||
candidate: match.candidate,
|
||||
piece,
|
||||
index: parts.indexOf(piece),
|
||||
index: ps.index,
|
||||
stackSize,
|
||||
facing: ps.facing,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -118,19 +183,23 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[]
|
||||
if (!enabled) continue;
|
||||
const surface = pkg.surfaces.get(surfaceId);
|
||||
if (!surface) continue;
|
||||
out.push(...computeSurfacePlacements(surface, state.paths));
|
||||
out.push(...computeSurfacePlacements(surface, state.parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A stable key for a placement, unique across surfaces, paths, and pieces. */
|
||||
/**
|
||||
* A stable key for a placement, unique across surfaces and pieces. A piece is
|
||||
* on exactly one path, so `path` is implied; it may still render on more than
|
||||
* one enabled surface, so the surface is part of the key.
|
||||
*/
|
||||
export function placementKey(p: Placement): string {
|
||||
return `${p.surface}:${p.path}:${p.piece}`;
|
||||
return `${p.surface}:${p.piece}`;
|
||||
}
|
||||
|
||||
/** The derived render state for a package, from the current game state. */
|
||||
export function useRenderState(pkg: Package): Placement[] {
|
||||
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||
const paths = useTabletopStore((s) => s.paths);
|
||||
return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]);
|
||||
const parts = useTabletopStore((s) => s.parts);
|
||||
return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]);
|
||||
}
|
||||
@@ -23,7 +23,9 @@ export function SurfaceBounds({ surface }: { surface: Surface }) {
|
||||
|
||||
return (
|
||||
<group>
|
||||
{surface.size && <Line points={points} color="#22d3ee" lineWidth={1} />}
|
||||
{surface.size && (
|
||||
<Line points={points} color="#22d3ee" lineWidth={1} depthTest={false} />
|
||||
)}
|
||||
<Html position={[0, 0.05, 0]} center style={{ pointerEvents: 'none' }}>
|
||||
<div className="rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
||||
{surface.type}#{surface.id}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { deserialize } from 'bson';
|
||||
import type { TTSMod } from '@tts/shared';
|
||||
import type { ModDetails, TTSMod } from '@tts/shared';
|
||||
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
|
||||
|
||||
const STEAM_API_URL =
|
||||
@@ -36,6 +36,30 @@ export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
||||
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.
|
||||
*
|
||||
@@ -94,6 +118,18 @@ async function downloadSave(fileUrl: string): Promise<ArrayBuffer> {
|
||||
|
||||
/** Resolve the `file_url` for a Workshop item via the Steam API. */
|
||||
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();
|
||||
params.append('key', apiKey);
|
||||
params.append('itemcount', '1');
|
||||
@@ -112,10 +148,7 @@ async function getFileUrl(id: string, apiKey: string): Promise<string> {
|
||||
if (!details) {
|
||||
throw new ItemNotFoundError(id);
|
||||
}
|
||||
if (!details.file_url) {
|
||||
throw new NoFileError(id);
|
||||
}
|
||||
return details.file_url;
|
||||
return details;
|
||||
}
|
||||
|
||||
export * from './errors.js';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user