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.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
|
|||||||
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
||||||
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
||||||
|
|
||||||
See [`docs/architecture.md`](docs/architecture.md) for the architecture and
|
See [`docs/overview.md`](docs/overview.md) for the docs index,
|
||||||
[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan.
|
[`docs/architecture.md`](docs/architecture.md) for the architecture, and
|
||||||
|
[`docs/status/implementation-plan.md`](docs/status/implementation-plan.md) for the plan.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { setGamesRoot } from './config.js';
|
|||||||
import asset from './routes/asset.js';
|
import asset from './routes/asset.js';
|
||||||
import health from './routes/health.js';
|
import health from './routes/health.js';
|
||||||
import items from './routes/items.js';
|
import items from './routes/items.js';
|
||||||
|
import pdf from './routes/pdf.js';
|
||||||
import search from './routes/search.js';
|
import search from './routes/search.js';
|
||||||
import trace from './routes/trace.js';
|
import trace from './routes/trace.js';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ app.route('/health', health);
|
|||||||
app.route('/search', search);
|
app.route('/search', search);
|
||||||
app.route('/items', items);
|
app.route('/items', items);
|
||||||
app.route('/asset', asset);
|
app.route('/asset', asset);
|
||||||
|
app.route('/pdf', pdf);
|
||||||
app.route('/trace', trace);
|
app.route('/trace', trace);
|
||||||
|
|
||||||
serve(
|
serve(
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails } from '@tts/shared';
|
||||||
import items from './items.js';
|
import items from './items.js';
|
||||||
|
|
||||||
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
GameMode: 'Tabletop',
|
||||||
|
Date: '2024-01-01',
|
||||||
|
ObjectStates: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const details: ModDetails = { mod };
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -17,7 +25,7 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new TtsError('STEAM_API_KEY is not configured', 500),
|
new TtsError('STEAM_API_KEY is not configured', 500),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
||||||
@@ -26,11 +34,6 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads from a fileUrl without an API key', async () => {
|
it('loads from a fileUrl without an API key', async () => {
|
||||||
const mod: TTSMod = {
|
|
||||||
GameMode: 'Tabletop',
|
|
||||||
Date: '2024-01-01',
|
|
||||||
ObjectStates: [],
|
|
||||||
};
|
|
||||||
const fetchModFromUrl = vi
|
const fetchModFromUrl = vi
|
||||||
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
||||||
.mockResolvedValue(mod);
|
.mockResolvedValue(mod);
|
||||||
@@ -40,26 +43,26 @@ describe('items route', () => {
|
|||||||
{ STEAM_API_KEY: '', PORT: 3000 },
|
{ STEAM_API_KEY: '', PORT: 3000 },
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual(mod);
|
expect(await res.json()).toEqual(details);
|
||||||
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the parsed mod on success', async () => {
|
it('returns the parsed mod with metadata on success', async () => {
|
||||||
const mod: TTSMod = {
|
const withMeta: ModDetails = {
|
||||||
GameMode: 'Tabletop',
|
mod,
|
||||||
Date: '2024-01-01',
|
title: 'My Mod',
|
||||||
ObjectStates: [],
|
previewImageUrl: 'https://example.com/preview.png',
|
||||||
};
|
};
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod);
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
|
||||||
|
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual(mod);
|
expect(await res.json()).toEqual(withMeta);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps TtsError subclasses to their status', async () => {
|
it('maps TtsError subclasses to their status', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new ItemNotFoundError('123'),
|
new ItemNotFoundError('123'),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
@@ -70,7 +73,7 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 for unexpected errors', async () => {
|
it('returns 500 for unexpected errors', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new Error('boom'),
|
new Error('boom'),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
import { itemIdSchema, type ModDetails } from '@tts/shared';
|
||||||
import {
|
import {
|
||||||
fetchMod,
|
fetchModDetails,
|
||||||
fetchModFile,
|
fetchModFile,
|
||||||
fetchModFileFromUrl,
|
fetchModFileFromUrl,
|
||||||
fetchModFromUrl,
|
fetchModFromUrl,
|
||||||
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const fileUrl = c.req.query('fileUrl');
|
const fileUrl = c.req.query('fileUrl');
|
||||||
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
||||||
// search result). Otherwise resolve it via the Steam API.
|
// search result). Otherwise resolve it via the Steam API, which also
|
||||||
const mod: TTSMod = fileUrl
|
// yields the Workshop title and preview image.
|
||||||
? await fetchModFromUrl(fileUrl)
|
const details: ModDetails = fileUrl
|
||||||
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
|
? { mod: await fetchModFromUrl(fileUrl) }
|
||||||
return c.json<TTSMod>(mod);
|
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
|
||||||
|
return c.json<ModDetails>(details);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TtsError) {
|
if (err instanceof TtsError) {
|
||||||
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import pdf from './pdf.js';
|
||||||
|
import { setGamesRoot } from '../config.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
|
||||||
|
setGamesRoot(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
setGamesRoot(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pdf route', () => {
|
||||||
|
it('rejects a missing url', async () => {
|
||||||
|
const res = await pdf.request('/');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: 'Missing url query param' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-http url', async () => {
|
||||||
|
const res = await pdf.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams the fetched pdf inline with a pdf content-type', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { 'content-type': 'application/pdf' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('content-disposition')).toBe('inline');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when the upstream fetch fails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
|
||||||
|
);
|
||||||
|
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a local game asset from GAMES_ROOT', async () => {
|
||||||
|
writeFileSync(path.join(dir, 'rules.pdf'), new Uint8Array([1, 2, 3]));
|
||||||
|
const res = await pdf.request('/?url=rules.pdf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('content-disposition')).toBe('inline');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { GAMES_ROOT } from '../config.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a PDF and stream it back inline so the browser renders it instead of
|
||||||
|
* downloading. PDF hosts often send `Content-Disposition: attachment`, which
|
||||||
|
* forces a download even when the URL is opened in a tab or iframe. This route
|
||||||
|
* strips that header and forces `inline` so the PDF can be embedded in the web
|
||||||
|
* app's viewer. Like `/asset`, a relative URL (no scheme) is treated as a game
|
||||||
|
* asset path relative to `GAMES_ROOT`.
|
||||||
|
*/
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
const raw = c.req.query('url');
|
||||||
|
if (!raw) {
|
||||||
|
return c.json({ error: 'Missing url query param' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolveAsset(raw, GAMES_ROOT);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json(
|
||||||
|
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
|
||||||
|
result.reason === 'not-found' ? 404 : 400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const asset = result.asset;
|
||||||
|
|
||||||
|
// A local game asset is streamed from disk; a remote one is fetched.
|
||||||
|
let body: BodyInit;
|
||||||
|
if (asset.stream) {
|
||||||
|
body = asset.stream as unknown as BodyInit;
|
||||||
|
} else {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(asset.url!);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: `Failed to fetch pdf: ${String(err)}` }, 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return c.json({ error: `Pdf responded ${res.status}` }, 502);
|
||||||
|
}
|
||||||
|
body = res.body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'inline',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@tts/http": "workspace:*",
|
"@tts/http": "workspace:*",
|
||||||
"@tts/tabletop": "workspace:*",
|
"@tts/tabletop": "workspace:*",
|
||||||
"bson": "^7.3.1",
|
"bson": "^7.3.1",
|
||||||
|
"postprocessing": "^6.36.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
|||||||
+29
-15
@@ -1,7 +1,8 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { Link, Route, Routes } from 'react-router-dom';
|
import { Link, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import SearchPage from './pages/SearchPage';
|
import SearchPage from './pages/SearchPage';
|
||||||
import ModPage from './pages/ModPage';
|
import ModPage from './pages/ModPage';
|
||||||
|
import ModHeader from './components/ModHeader';
|
||||||
|
|
||||||
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
|
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
|
||||||
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
|
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
|
||||||
@@ -18,24 +19,36 @@ const SetupsPage = lazy(() => import('./pages/SetupsPage'));
|
|||||||
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const pathname = useLocation().pathname;
|
||||||
|
const isModRoute = pathname.startsWith('/mod/');
|
||||||
|
// The inspector view is full-bleed/viewport-height; the setup sub-route keeps the standard centered layout.
|
||||||
|
const isModView = /^\/mod\/[^/]+$/.test(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||||
<header className="border-b border-zinc-800">
|
{/* For the mod route the layout is full-bleed and fills the viewport so
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
the sidebar can scroll independently and the viewer gets all the space. */}
|
||||||
<Link to="/" className="text-lg font-semibold tracking-tight">
|
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
|
||||||
TTS Workshop
|
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
|
||||||
</Link>
|
{isModRoute ? (
|
||||||
<nav className="flex gap-4 text-sm text-zinc-400">
|
<ModHeader />
|
||||||
<Link to="/" className="hover:text-zinc-100">
|
) : (
|
||||||
Search
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||||
|
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||||
|
TTS Workshop
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/bgm" className="hover:text-zinc-100">
|
<nav className="flex gap-4 text-sm text-zinc-400">
|
||||||
BGM
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
</Link>
|
Search
|
||||||
</nav>
|
</Link>
|
||||||
</div>
|
<Link to="/bgm" className="hover:text-zinc-100">
|
||||||
|
BGM
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
<main className={(isModView ? "min-h-0 flex-1" : "mx-auto max-w-5xl px-4 py-8")}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
@@ -57,6 +70,7 @@ export default function App() {
|
|||||||
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import type { SearchResult, TTSMod } from '@tts/shared';
|
import type { ModDetails, SearchResult } from '@tts/shared';
|
||||||
import { traceImage } from '@tts/http';
|
import { traceImage } from '@tts/http';
|
||||||
|
|
||||||
export { traceImage };
|
export { traceImage };
|
||||||
@@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a full parsed TTS save.
|
* Fetch a full parsed TTS save plus its Workshop metadata.
|
||||||
*/
|
*/
|
||||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (fileUrl) params.set('fileUrl', fileUrl);
|
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a URL for the raw save file download. */
|
/** Build a URL for the raw save file download. */
|
||||||
|
|||||||
@@ -0,0 +1,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 { Icon } from '@iconify/react';
|
||||||
import type { ObjectTreeNode } from '@tts/extract';
|
import type { ObjectTreeNode } from '@tts/extract';
|
||||||
import { iconsForObject } from './objectIcons';
|
import { iconsForObject } from './objectIcons';
|
||||||
@@ -10,19 +10,85 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
||||||
|
const [highlighted, setHighlighted] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
const types = useMemo(() => collectTypes(nodes), [nodes]);
|
||||||
|
|
||||||
|
// When nothing is highlighted, every type is shown. Otherwise only the
|
||||||
|
// highlighted types are visible.
|
||||||
|
const visibleNodes = useMemo(
|
||||||
|
() => filterTree(nodes, highlighted),
|
||||||
|
[nodes, highlighted],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleType = (name: string) =>
|
||||||
|
setHighlighted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAll = () => setHighlighted(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{nodes.map((node, index) => (
|
{types.length > 0 && (
|
||||||
<TreeNode
|
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
|
||||||
key={index}
|
<div className="grid grid-cols-6 gap-1">
|
||||||
node={node}
|
{types.map(({ name, count }) => {
|
||||||
depth={0}
|
const active = highlighted.has(name);
|
||||||
path={`${index}`}
|
return (
|
||||||
selectedPath={selectedPath}
|
<button
|
||||||
onSelect={onSelect}
|
key={name}
|
||||||
/>
|
onClick={() => toggleType(name)}
|
||||||
))}
|
aria-pressed={active}
|
||||||
</ul>
|
title={`${name} (${count}) — ${active ? 'unhighlight' : 'highlight'}`}
|
||||||
|
className={`relative flex h-8 items-center justify-center rounded-md border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
|
||||||
|
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{iconsForObject(name).map((icon) => (
|
||||||
|
<Icon key={icon} icon={icon} className="h-4 w-4" />
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-1 -right-1 rounded bg-zinc-950 px-0.5 font-mono text-[9px] leading-tight ${
|
||||||
|
active ? 'text-zinc-200' : 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{highlighted.size > 0 && (
|
||||||
|
<div className="mt-1.5 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-3 pt-2">
|
||||||
|
{visibleNodes.map(({ node, path }) => (
|
||||||
|
<TreeNode
|
||||||
|
key={path}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
path={path}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +150,16 @@ function TreeNode({
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{node.label}</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
@@ -103,3 +179,50 @@ function TreeNode({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinct object types present in the tree, with a count of each. */
|
||||||
|
function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const visit = (node: ObjectTreeNode) => {
|
||||||
|
counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1);
|
||||||
|
node.children.forEach(visit);
|
||||||
|
};
|
||||||
|
nodes.forEach(visit);
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a node when its type is highlighted or any descendant survives, so the
|
||||||
|
* containment hierarchy is preserved and highlighted parents still lead to
|
||||||
|
* highlighted children. With nothing highlighted, every node is kept.
|
||||||
|
*
|
||||||
|
* Each result carries the node's original index path into the full (unfiltered)
|
||||||
|
* tree, so selection stays stable regardless of filtering.
|
||||||
|
*/
|
||||||
|
function filterTree(
|
||||||
|
nodes: ObjectTreeNode[],
|
||||||
|
highlighted: Set<string>,
|
||||||
|
prefix = '',
|
||||||
|
): { node: ObjectTreeNode; path: string }[] {
|
||||||
|
const result: { node: ObjectTreeNode; path: string }[] = [];
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const path = prefix ? `${prefix}-${index}` : `${index}`;
|
||||||
|
const children = filterTree(node.children, highlighted, path);
|
||||||
|
const visible =
|
||||||
|
highlighted.size === 0 ||
|
||||||
|
highlighted.has(node.object.Name) ||
|
||||||
|
children.length > 0;
|
||||||
|
if (visible) {
|
||||||
|
result.push({
|
||||||
|
node:
|
||||||
|
children.length > 0
|
||||||
|
? { ...node, children: children.map((c) => c.node) }
|
||||||
|
: node,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -56,6 +56,9 @@ export default function TabletopScene({
|
|||||||
enablePan
|
enablePan
|
||||||
fullscreen
|
fullscreen
|
||||||
shadowScale={shadowScale}
|
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={
|
overlay={
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSurface((v) => !v)}
|
onClick={() => setShowSurface((v) => !v)}
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
export interface ObjectViewer {
|
export interface ObjectViewer {
|
||||||
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
||||||
name: string;
|
name: string;
|
||||||
component: (props: { object: TTSObject }) => ReactNode;
|
component: (props: ViewerProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props passed to every object viewer. */
|
||||||
|
export interface ViewerProps {
|
||||||
|
object: TTSObject;
|
||||||
|
/** Expand the 3D scene to fill its container instead of the default frame. */
|
||||||
|
fill?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = new Map<string, ObjectViewer['component']>();
|
const registry = new Map<string, ObjectViewer['component']>();
|
||||||
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The default viewer: a plain inspection of the object's fields. */
|
/** The default viewer: a plain inspection of the object's fields. */
|
||||||
export function DefaultViewer({ object }: { object: TTSObject }) {
|
export function DefaultViewer({ object }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
{Object.entries(object).map(([key, value]) => {
|
{Object.entries(object).map(([key, value]) => {
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ import {
|
|||||||
} from '@tts/mesh';
|
} from '@tts/mesh';
|
||||||
import { assetUrl } from '@tts/http';
|
import { assetUrl } from '@tts/http';
|
||||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||||
import { flipTexture } from './flipTexture';
|
import { applyMapTransform } from './cardMaterial';
|
||||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
/** Longer card dimension, in world units. */
|
/** Longer card dimension, in world units. */
|
||||||
const CARD_LENGTH = 2;
|
const CARD_LENGTH = 2;
|
||||||
/** Corner radius as a fraction of the shorter card edge. */
|
/** Corner radius as a fraction of the shorter card edge. */
|
||||||
const CORNER_RADIUS = 0.05;
|
const CORNER_RADIUS = 0.05;
|
||||||
/** Thickness of the card. */
|
/** Thickness of the card, as a fraction of its length (real cards are ~0.3%). */
|
||||||
const CARD_THICKNESS = 0.06;
|
const CARD_THICKNESS = 0.01;
|
||||||
|
|
||||||
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
||||||
// Without it, the face/back hooks would be called conditionally, which breaks
|
// 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 face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||||
|
|
||||||
// Front texture: the sprite cell from the sheet (or the full image when there
|
// Card art is sRGB-encoded. `TextureLoader` leaves `colorSpace` as
|
||||||
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
|
// `NoColorSpace`, which uploads the texture as linear and then double-decodes
|
||||||
// that share the same sheet URL (drei caches textures globally by URL).
|
// it in the shader, washing out contrast. Mark it sRGB so the GPU decodes it
|
||||||
const faceMap = useMemo(() => {
|
// once, correctly. Clones (e.g. `flipTexture`) inherit this from the source.
|
||||||
if (!faceUrl) return null;
|
face.colorSpace = THREE.SRGBColorSpace;
|
||||||
const tex = face.clone();
|
back.colorSpace = THREE.SRGBColorSpace;
|
||||||
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]);
|
|
||||||
|
|
||||||
// Back texture: a single full image (tile) unless the deck has unique backs,
|
// The face/back textures are shared (drei caches them by URL); each card's
|
||||||
// in which case it's a sheet too. Flipped left/right so it reads correctly
|
// sprite cell is selected via a per-material UV transform injected into the
|
||||||
// instead of being mirrored on the back face.
|
// shader, so no per-card texture clone (and no re-upload) is needed. The
|
||||||
const backMap = useMemo(() => {
|
// transform is baked into the material's shader, so it must be keyed into the
|
||||||
if (!backUrl) return null;
|
// shared-material cache to avoid mutating a material used by another card.
|
||||||
const tex = back.clone();
|
const faceMap = faceUrl ? face : null;
|
||||||
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
|
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)
|
? spriteUv(cardId, numWidth, numHeight)
|
||||||
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
|
||||||
tex.repeat.set(repeatX, repeatY);
|
: null;
|
||||||
tex.offset.set(offsetX, offsetY);
|
|
||||||
return flipTexture(tex);
|
// Key by URL + card id + tint: the URL disambiguates different sheets (and
|
||||||
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
|
// `CardCustom` objects, which have no `CardID`), the card id selects the
|
||||||
|
// sprite cell, and the tint bakes the per-object color in.
|
||||||
|
const faceMat = getSharedMaterial(`card-face:${faceUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
|
||||||
|
color: tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: faceMap ?? undefined,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
if (faceUv) {
|
||||||
|
applyMapTransform(faceMat, new THREE.Vector2(faceUv.repeatX, faceUv.repeatY), new THREE.Vector2(faceUv.offsetX, faceUv.offsetY));
|
||||||
|
}
|
||||||
|
|
||||||
|
const backMat = getSharedMaterial(`card-back:${backUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
|
||||||
|
color: tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: backMap ?? undefined,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
if (backUv) {
|
||||||
|
// The back cap maps with the same planar UVs as the front, so mirror the
|
||||||
|
// sprite cell left/right to read correctly instead of appearing mirrored.
|
||||||
|
// Negating repeat.x and shifting offset.x by one repeat keeps the visible
|
||||||
|
// region in place while mirrored (see `flipTexture`).
|
||||||
|
applyMapTransform(
|
||||||
|
backMat,
|
||||||
|
new THREE.Vector2(-backUv.repeatX, backUv.repeatY),
|
||||||
|
new THREE.Vector2(backUv.offsetX + backUv.repeatX, backUv.offsetY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallMat = getSharedMaterial(`card-wall:${tintK}`, {
|
||||||
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
||||||
// front and back faces each get their own material; the walls are a solid
|
// front and back faces each get their own material; the walls are a solid
|
||||||
// white, matching TTS card tinting. Geometry is shared across cards of the
|
// white, matching TTS card tinting. Geometry is shared across cards of the
|
||||||
// same size so the full-setup view reuses it; the face/back materials stay
|
// same size so the full-setup view reuses it; the face/back materials are
|
||||||
// per-card because each card clones its texture for sprite UVs.
|
// shared per card (keyed by card id + tint) and carry the sprite UV transform.
|
||||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||||
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
||||||
| HTMLImageElement
|
| HTMLImageElement
|
||||||
@@ -124,23 +161,9 @@ export function CardMesh({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh geometry={frontGeo}>
|
<mesh geometry={frontGeo} material={faceMat} />
|
||||||
<meshStandardMaterial
|
<mesh geometry={backGeo} material={backMat} />
|
||||||
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
<mesh geometry={wallsGeo} material={wallMat} />
|
||||||
map={faceMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={backGeo}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
|
||||||
map={backMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={wallsGeo}>
|
|
||||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { CardObjectMesh } from './CardMesh';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A playing card: a thin rounded rect with the face texture on the front and
|
* A playing card: a thin rounded rect with the face texture on the front and
|
||||||
@@ -24,10 +25,15 @@ import { CardObjectMesh } from './CardMesh';
|
|||||||
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
||||||
* the card.
|
* the card.
|
||||||
*/
|
*/
|
||||||
export default function CardViewer({ object }: { object: TTSObject }) {
|
export default function CardViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CardObjectMesh object={object} />
|
{/* 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>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
|||||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||||
const texture = useTexture(assetUrl(url));
|
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.
|
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
root.traverse((child) => {
|
root.traverse((child) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { CustomModelMesh } from './CustomModelMesh';
|
import { CustomModelMesh } from './CustomModelMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
@@ -8,9 +9,9 @@ import { CustomModelMesh } from './CustomModelMesh';
|
|||||||
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
||||||
* model's materials when present.
|
* model's materials when present.
|
||||||
*/
|
*/
|
||||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
export default function CustomModelViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CustomModelMesh object={object} />
|
<CustomModelMesh object={object} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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 { Icon } from '@iconify/react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
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,
|
* 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
|
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
||||||
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
||||||
* expands the scene to the full screen.
|
* `maxPolarAngle` (radians) clamps how
|
||||||
|
* 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({
|
export default function Scene({
|
||||||
children,
|
children,
|
||||||
autoRotate = true,
|
autoRotate = true,
|
||||||
enablePan = false,
|
enablePan = false,
|
||||||
fullscreen = false,
|
fullscreen = false,
|
||||||
|
fit = true,
|
||||||
|
fill = false,
|
||||||
overlay,
|
overlay,
|
||||||
shadowScale = 22,
|
shadowScale = 22,
|
||||||
|
maxPolarAngle = Math.PI,
|
||||||
|
shadowBlur = 0.012,
|
||||||
|
shadow = true,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
enablePan?: boolean;
|
enablePan?: boolean;
|
||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
|
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
|
||||||
|
fit?: boolean;
|
||||||
|
/** Expand to fill the container height instead of the default aspect-[3/4] frame. */
|
||||||
|
fill?: boolean;
|
||||||
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
shadowScale?: number;
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
@@ -52,7 +82,9 @@ export default function Scene({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400"
|
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
|
||||||
|
fill ? 'h-full min-h-0' : 'aspect-3/4'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
{fullscreen && (
|
{fullscreen && (
|
||||||
@@ -71,33 +103,38 @@ export default function Scene({
|
|||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
gl={{ antialias: true, alpha: true }}
|
gl={{ antialias: true, alpha: true }}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.1} />
|
||||||
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
|
||||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
<Environment resolution={256}>
|
||||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
<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}>
|
<Suspense fallback={null}>
|
||||||
<Bounds fit observe clip>{children}</Bounds>
|
<Bounds fit={fit} observe={fit} clip={fit}>
|
||||||
|
<group ref={contentRef}>{children}</group>
|
||||||
|
</Bounds>
|
||||||
</Suspense>
|
</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
|
<OrbitControls
|
||||||
enablePan={enablePan}
|
enablePan={enablePan}
|
||||||
minDistance={0.01}
|
minDistance={0.01}
|
||||||
maxDistance={8}
|
maxDistance={8}
|
||||||
|
maxPolarAngle={maxPolarAngle}
|
||||||
autoRotate={autoRotate}
|
autoRotate={autoRotate}
|
||||||
makeDefault
|
makeDefault
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EffectComposer>
|
<EffectComposer>
|
||||||
|
<ToneMapping mode={ToneMappingMode.AGX} />
|
||||||
|
<BrightnessContrast brightness={0.12} contrast={.5} />
|
||||||
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
@@ -125,3 +162,84 @@ function LoadingOverlay() {
|
|||||||
</div>
|
</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;
|
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
|
// 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
|
// 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
|
// the tile matches the source proportions instead of being square. Shared
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { TileObjectMesh } from './TileMesh';
|
import { TileObjectMesh } from './TileMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||||
@@ -10,10 +11,13 @@ import { TileObjectMesh } from './TileMesh';
|
|||||||
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
||||||
* source image instead of being forced square.
|
* source image instead of being forced square.
|
||||||
*/
|
*/
|
||||||
export default function TileViewer({ object }: { object: TTSObject }) {
|
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TileObjectMesh object={object} />
|
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<TileObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export function TokenMesh({
|
|||||||
}) {
|
}) {
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
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
|
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
// 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
|
// 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 type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { TokenObjectMesh } from './TokenMesh';
|
import { TokenObjectMesh } from './TokenMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A token: a short extruded shape with the texture on its top face. Uses
|
* A token: a short extruded shape with the texture on its top face. Uses
|
||||||
@@ -8,10 +9,13 @@ import { TokenObjectMesh } from './TokenMesh';
|
|||||||
* color when absent. The footprint is traced from the image's alpha channel via
|
* color when absent. The footprint is traced from the image's alpha channel via
|
||||||
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
|
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
|
||||||
*/
|
*/
|
||||||
export default function TokenViewer({ object }: { object: TTSObject }) {
|
export default function TokenViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TokenObjectMesh object={object} />
|
{/* Lay the token flat on the ground (see CardViewer). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<TokenObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { applyMapTransform } from './cardMaterial';
|
||||||
|
|
||||||
|
describe('applyMapTransform', () => {
|
||||||
|
it('injects the repeat/offset uniforms and UV transform into the shader', () => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial();
|
||||||
|
applyMapTransform(mat, new THREE.Vector2(0.5, 0.25), new THREE.Vector2(0.1, 0.2));
|
||||||
|
|
||||||
|
expect(mat.onBeforeCompile).toBeTypeOf('function');
|
||||||
|
|
||||||
|
const shader = {
|
||||||
|
uniforms: {} as Record<string, { value: unknown }>,
|
||||||
|
vertexShader: '#include <uv_vertex>\nvoid main() {}',
|
||||||
|
};
|
||||||
|
mat.onBeforeCompile!(shader as never, {} as never);
|
||||||
|
|
||||||
|
// Uniforms are copied, so the caller's vectors stay reusable.
|
||||||
|
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(0.5, 0.25));
|
||||||
|
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0.1, 0.2));
|
||||||
|
// The uniforms are declared in the GLSL (three.js does not auto-declare
|
||||||
|
// uniforms added via `onBeforeCompile`), and the override is injected right
|
||||||
|
// after the chunk include, which stays in place (it declares `vMapUv`/`uv`).
|
||||||
|
expect(shader.vertexShader).toContain('uniform vec2 uMapRepeat;');
|
||||||
|
expect(shader.vertexShader).toContain('uniform vec2 uMapOffset;');
|
||||||
|
expect(shader.vertexShader).toContain('#include <uv_vertex>\n\tvMapUv = uv * uMapRepeat + uMapOffset;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the vectors so later mutation of the inputs has no effect', () => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial();
|
||||||
|
const repeat = new THREE.Vector2(1, 1);
|
||||||
|
const offset = new THREE.Vector2(0, 0);
|
||||||
|
applyMapTransform(mat, repeat, offset);
|
||||||
|
|
||||||
|
repeat.set(9, 9);
|
||||||
|
offset.set(9, 9);
|
||||||
|
|
||||||
|
const shader = { uniforms: {} as Record<string, { value: unknown }>, vertexShader: '' };
|
||||||
|
mat.onBeforeCompile!(shader as never, {} as never);
|
||||||
|
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(1, 1));
|
||||||
|
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-card UV transform injected into a `MeshStandardMaterial` shader.
|
||||||
|
*
|
||||||
|
* Cards share one texture (the deck's sprite sheet, cached by drei) and one
|
||||||
|
* geometry, but each card samples a different sprite cell. Rather than cloning
|
||||||
|
* the texture per card (which re-uploads the sheet on every GPU bind), the
|
||||||
|
* repeat/offset is pushed into the material as a uniform. The shader source is
|
||||||
|
* identical across cards, so three.js still compiles a single shared program.
|
||||||
|
*
|
||||||
|
* We inject our own uniform instead of setting `texture.repeat`/`offset`
|
||||||
|
* because three r185 derives the map UVs from a `mapTransform` matrix that is
|
||||||
|
* refreshed from `map.matrix` every frame, overwriting any per-material
|
||||||
|
* transform we set on the shared texture.
|
||||||
|
*/
|
||||||
|
// Uniforms added via `onBeforeCompile` are not auto-declared by three.js, so
|
||||||
|
// they must be declared in the GLSL explicitly (the built-in `mapTransform` is
|
||||||
|
// declared in `uv_pars_vertex.glsl.js`).
|
||||||
|
const UNIFORM_DECLS = /* glsl */ `
|
||||||
|
uniform vec2 uMapRepeat;
|
||||||
|
uniform vec2 uMapOffset;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VERTEX_INJECT = /* glsl */ `
|
||||||
|
#include <uv_vertex>
|
||||||
|
vMapUv = uv * uMapRepeat + uMapOffset;
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a repeat/offset to a card material's map sampling. Call once per
|
||||||
|
* material (the transform is baked into the shader). `repeat`/`offset` are
|
||||||
|
* copied, so the caller may reuse the vectors.
|
||||||
|
*/
|
||||||
|
export function applyMapTransform(
|
||||||
|
material: THREE.MeshStandardMaterial,
|
||||||
|
repeat: THREE.Vector2,
|
||||||
|
offset: THREE.Vector2,
|
||||||
|
): void {
|
||||||
|
// Clone eagerly so later mutation of the caller's vectors can't leak into
|
||||||
|
// the uniform once the material is compiled.
|
||||||
|
const r = repeat.clone();
|
||||||
|
const o = offset.clone();
|
||||||
|
material.onBeforeCompile = (shader) => {
|
||||||
|
shader.uniforms.uMapRepeat = { value: r };
|
||||||
|
shader.uniforms.uMapOffset = { value: o };
|
||||||
|
shader.vertexShader =
|
||||||
|
UNIFORM_DECLS +
|
||||||
|
shader.vertexShader.replace('#include <uv_vertex>', VERTEX_INJECT);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,16 +8,19 @@ import { registerViewer } from '../viewers';
|
|||||||
const TileViewer = lazy(() => import('./TileViewer'));
|
const TileViewer = lazy(() => import('./TileViewer'));
|
||||||
const TokenViewer = lazy(() => import('./TokenViewer'));
|
const TokenViewer = lazy(() => import('./TokenViewer'));
|
||||||
const CardViewer = lazy(() => import('./CardViewer'));
|
const CardViewer = lazy(() => import('./CardViewer'));
|
||||||
|
const DeckViewer = lazy(() => import('./DeckViewer'));
|
||||||
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
||||||
|
const PdfViewer = lazy(() => import('./PdfViewer'));
|
||||||
|
|
||||||
registerViewer('Tile', TileViewer);
|
registerViewer('Tile', TileViewer);
|
||||||
registerViewer('Custom_Tile', TileViewer);
|
registerViewer('Custom_Tile', TileViewer);
|
||||||
registerViewer('Custom_Token', TokenViewer);
|
registerViewer('Custom_Token', TokenViewer);
|
||||||
registerViewer('Card', CardViewer);
|
registerViewer('Card', CardViewer);
|
||||||
registerViewer('CardCustom', CardViewer);
|
registerViewer('CardCustom', CardViewer);
|
||||||
registerViewer('Deck', CardViewer);
|
registerViewer('Deck', DeckViewer);
|
||||||
registerViewer('DeckCustom', CardViewer);
|
registerViewer('DeckCustom', DeckViewer);
|
||||||
registerViewer('Custom_Deck', CardViewer);
|
registerViewer('Custom_Deck', DeckViewer);
|
||||||
registerViewer('Custom_Model', CustomModelViewer);
|
registerViewer('Custom_Model', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
||||||
|
registerViewer('Custom_PDF', PdfViewer);
|
||||||
@@ -30,7 +30,7 @@ export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
|
|||||||
* the same image.
|
* the same image.
|
||||||
*
|
*
|
||||||
* These caches live for the session (like drei's global texture cache) and are
|
* 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>();
|
const geometryCache = new Map<string, THREE.BufferGeometry>();
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
|
|||||||
}, [id, item?.fileUrl, load]);
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
const objects = useMemo(
|
const objects = useMemo(
|
||||||
() => (mod ? flattenObjects(mod) : []),
|
() => (mod ? flattenObjects(mod.mod) : []),
|
||||||
[mod],
|
[mod],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Link, useParams } from 'react-router-dom';
|
|||||||
import { buildTree, collectRefs } from '@tts/extract';
|
import { buildTree, collectRefs } from '@tts/extract';
|
||||||
import { useModStore } from '../stores/modStore';
|
import { useModStore } from '../stores/modStore';
|
||||||
import { useSearchStore } from '../stores/searchStore';
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
import { modFileUrl } from '../api';
|
|
||||||
import ObjectTree from '../components/ObjectTree';
|
import ObjectTree from '../components/ObjectTree';
|
||||||
import { ErrorBoundary } from '@tts/tabletop';
|
import { ErrorBoundary } from '@tts/tabletop';
|
||||||
import { resolveViewer } from '../components/viewers';
|
import { resolveViewer } from '../components/viewers';
|
||||||
@@ -25,8 +24,8 @@ export default function ModPage() {
|
|||||||
if (id) load(id, item?.fileUrl);
|
if (id) load(id, item?.fileUrl);
|
||||||
}, [id, item?.fileUrl, load]);
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
|
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||||
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
|
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
||||||
@@ -40,78 +39,37 @@ export default function ModPage() {
|
|||||||
const Viewer = selected ? resolveViewer(selected.object) : null;
|
const Viewer = selected ? resolveViewer(selected.object) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex h-full min-h-0">
|
||||||
<div>
|
{/* Independently scrolling tree; the filter bar stays pinned above it. */}
|
||||||
<h1 className="text-2xl font-semibold">Mod {id}</h1>
|
<aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<ObjectTree
|
||||||
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
|
nodes={tree}
|
||||||
asset refs
|
selectedPath={selectedPath}
|
||||||
</p>
|
onSelect={setSelectedPath}
|
||||||
<div className="mt-3 flex gap-2">
|
/>
|
||||||
<a
|
</aside>
|
||||||
href={modFileUrl(id!, item?.fileUrl)}
|
|
||||||
className="inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
|
||||||
>
|
|
||||||
Download save file
|
|
||||||
</a>
|
|
||||||
<Link
|
|
||||||
to={`/mod/${id}/setup`}
|
|
||||||
className="inline-block rounded-lg border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Full setup
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
<section className="h-full min-h-0 flex-1 p-4">
|
||||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
|
{selected && Viewer ? (
|
||||||
<ObjectTree
|
/* Key by selection path so the Canvas remounts and the camera
|
||||||
nodes={tree}
|
refits to the newly selected object. */
|
||||||
selectedPath={selectedPath}
|
<ErrorBoundary key={selectedPath}>
|
||||||
onSelect={setSelectedPath}
|
<Suspense
|
||||||
/>
|
fallback={
|
||||||
</aside>
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
|
Loading viewer…
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
</div>
|
||||||
{selected ? (
|
}
|
||||||
<>
|
>
|
||||||
<header className="mb-4">
|
<Viewer object={selected.object} fill />
|
||||||
<span
|
</Suspense>
|
||||||
title={selected.object.Name}
|
</ErrorBoundary>
|
||||||
className="inline-flex items-center gap-1 text-zinc-400"
|
) : (
|
||||||
>
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
{iconsForObject(selected.object.Name).map((icon) => (
|
Select an object from the tree to inspect it.
|
||||||
<Icon key={icon} icon={icon} className="h-5 w-5" />
|
</div>
|
||||||
))}
|
)}
|
||||||
</span>
|
</section>
|
||||||
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{selected.object.GUID}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
{Viewer && (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
|
||||||
Loading viewer…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Key by selection path so the Canvas remounts and the
|
|
||||||
camera refits to the newly selected object. */}
|
|
||||||
<Viewer key={selectedPath} object={selected.object} />
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Select an object from the tree to inspect it.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails } from '@tts/shared';
|
||||||
import { fetchMod } from '../api';
|
import { fetchMod } from '../api';
|
||||||
|
|
||||||
interface ModState {
|
interface ModState {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
mod: TTSMod | null;
|
mod: ModDetails | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
load: (id: string, fileUrl?: string) => Promise<void>;
|
load: (id: string, fileUrl?: string) => Promise<void>;
|
||||||
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
|
|||||||
load: async (id, fileUrl) => {
|
load: async (id, fileUrl) => {
|
||||||
set({ loading: true, error: null, id, fileUrl });
|
set({ loading: true, error: null, id, fileUrl });
|
||||||
try {
|
try {
|
||||||
const mod = await fetchMod(id, fileUrl);
|
const details = await fetchMod(id, fileUrl);
|
||||||
set({ mod, loading: false });
|
set({ mod: details, loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: String(err) });
|
set({ loading: false, error: String(err) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
|
clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
|
||||||
}));
|
}));
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
'/items': 'http://localhost:3000',
|
'/items': 'http://localhost:3000',
|
||||||
'/health': 'http://localhost:3000',
|
'/health': 'http://localhost:3000',
|
||||||
'/asset': 'http://localhost:3000',
|
'/asset': 'http://localhost:3000',
|
||||||
|
'/pdf': 'http://localhost:3000',
|
||||||
'/trace': 'http://localhost:3000',
|
'/trace': 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Scope:** The system's architecture and dependency graph. For implementation
|
> **Scope:** The system's architecture and dependency graph. For implementation
|
||||||
> details (files, endpoints, build order), see
|
> details (files, endpoints, build order), see
|
||||||
> [`implementation-plan.md`](./implementation-plan.md). For the rationale behind
|
> [`status/implementation-plan.md`](./status/implementation-plan.md). For the
|
||||||
> key decisions, see [`decisions.md`](./decisions.md).
|
> rationale behind key decisions, see [`decisions.md`](./decisions.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ shared types/validation package.
|
|||||||
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
||||||
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
|
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
|
||||||
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
||||||
|
| `packages/engine` | Message bus, queue/tick, triggers, orchestrators | Isomorphic |
|
||||||
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
||||||
|
|
||||||
## Dependency graph
|
## Dependency graph
|
||||||
|
|||||||
@@ -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
|
### Inline vs file
|
||||||
|
|
||||||
`$variants` can be a file/URL path *or* an inline CSV string. If the value
|
`$variants` can be a single source or an array of sources. Each source is a
|
||||||
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
|
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
|
||||||
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
|
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
|
```yaml
|
||||||
$variants: |
|
$variants: |
|
||||||
@@ -47,9 +49,21 @@ $variants: |
|
|||||||
string,string,[number;number;number;number]
|
string,string,[number;number;number;number]
|
||||||
fish,Fish,[0;0;5;2]
|
fish,Fish,[0;0;5;2]
|
||||||
grain,Grain,[1;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 conventions
|
||||||
|
|
||||||
CSV is parsed with `typed-csv`:
|
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
|
### Code blocks as virtual files
|
||||||
|
|
||||||
A code block is a virtual definition file. To give it a name — so `include:`
|
A code block is a virtual definition file. Its name is derived from the
|
||||||
and `$variants` paths can resolve against it — add a `file=` segment to the
|
`role=` on its info string — `role.type.lang` — so it is discoverable by the
|
||||||
code block's info string. The name is relative to the current markdown file:
|
default `include: ./**/*.yaml` and addressable by that name:
|
||||||
|
|
||||||
````md
|
````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.
|
- `role=part.cargo` names the block `part.cargo.yaml`.
|
||||||
- A block without `file=` is auto-named `./${hash}.yaml`, where `hash`
|
- `role=surface.game#main` names it `surface.game.yaml`.
|
||||||
is derived from its content. This makes every yaml block naturally
|
- `role=package` names it `package.yaml`.
|
||||||
discoverable by the default `include: ./**/*.yaml`. Identical blocks dedupe
|
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||||
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.
|
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
|
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
|
||||||
optional and only for editor highlighting.
|
names the block `parts/cargo.yaml` regardless of its role.
|
||||||
- **Hash vs explicit `file=`:** a hashed name is for auto-discovery, not for
|
- A block without `role=` is **not** a definition — it is ignored. Discovery
|
||||||
referencing. To point at a specific yaml block by name, give it an explicit
|
is explicit: a block is a definition only when its `role=` (or, for real
|
||||||
`file=`; otherwise its name is content-derived and unstable.
|
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
|
### include
|
||||||
|
|
||||||
@@ -115,17 +174,24 @@ package can use a `../`-relative pattern or an absolute-from-root pattern
|
|||||||
|
|
||||||
## 3. Roles
|
## 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`
|
- `package`
|
||||||
- `part`
|
- `part`
|
||||||
- `surface`
|
- `surface`
|
||||||
- `setup`
|
- `setup`
|
||||||
|
- `dialog`
|
||||||
|
|
||||||
a valid object can either be the root or in the list of the yaml block.
|
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.
|
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.
|
`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
|
### package
|
||||||
|
|
||||||
The package is the container for a game's definitions. It is declared with a
|
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
|
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
||||||
ratio is kept, but not z (thickness).
|
ratio is kept, but not z (thickness).
|
||||||
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
|
- `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
|
#### example
|
||||||
|
|
||||||
@@ -268,19 +338,81 @@ surfaces:
|
|||||||
- board#harbor
|
- board#harbor
|
||||||
- hud#hand
|
- hud#hand
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:boat#fleet
|
- path: /dock/0
|
||||||
/deck: harbor:card
|
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
|
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
||||||
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
||||||
enabled.
|
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
|
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).
|
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
|
### Routing
|
||||||
|
|
||||||
A route is a **visualization route**: it maps a part to a location on a
|
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
|
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
|
||||||
same route path may appear more than once:
|
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
|
```yaml
|
||||||
layout:
|
layout:
|
||||||
- route: /dock/:seat
|
- 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
|
# 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
|
## 1. stack
|
||||||
|
|
||||||
`react` - react, react router, tailwindv4
|
`react` - react, react router, tailwind v4
|
||||||
`r3f` - r3f, drei, postprocessing
|
`r3f` - r3f, drei, postprocessing
|
||||||
`zustand` - for state management
|
`zustand` - for state management
|
||||||
|
|
||||||
@@ -15,18 +20,25 @@ source-of-truth game state:
|
|||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
surfaces: Record<string, boolean>, // enabled per surface id
|
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.
|
- `route` - the matched route.
|
||||||
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
- `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.
|
- `index` - the piece's position in its path's stack.
|
||||||
- `stackSize` - the number of pieces on the path.
|
- `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.
|
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
|
## 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.
|
- 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.
|
- 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.
|
||||||
+65
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Scope:** The rationale behind key design decisions. For the system's
|
> **Scope:** The rationale behind key design decisions. For the system's
|
||||||
> architecture, see [`architecture.md`](./architecture.md). For the concrete
|
> 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.
|
> Each entry records the decision, the context, and the alternatives considered.
|
||||||
> New entries are appended; existing entries are updated only to correct facts,
|
> New entries are appended; existing entries are updated only to correct facts,
|
||||||
@@ -260,3 +260,67 @@ boundary.
|
|||||||
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
||||||
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
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
|
# bgm Loader — Status
|
||||||
|
|
||||||
> WIP. What's built, what works, what's missing, and the known issues.
|
> Status: current. What's built, what works, what's missing, and the known
|
||||||
> Spec: [`bgm-format.md`](./bgm-format.md).
|
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
|
||||||
|
|
||||||
## What's built
|
## What's built
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
|
|
||||||
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
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)
|
### `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)
|
### `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)
|
### `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.
|
- **`$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`.
|
- **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).
|
- **`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).
|
- Docs for the loader itself (this file is the start).
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
# bgm-tabletop — Implementation Plan / Status
|
# bgm-tabletop — Implementation Plan / Status
|
||||||
|
|
||||||
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
> **Scope:** A standalone r3f component library that renders
|
||||||
> board games: a state store, surface mounting, part placement with stacking,
|
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
|
||||||
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
> 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
|
> **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
|
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
|
||||||
> part-inspection route renders `PartView` from the library.
|
> 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`) ✅
|
### 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
|
```ts
|
||||||
interface GameState {
|
interface GameState {
|
||||||
surfaces: Record<string, boolean>; // enabled per surface id
|
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`.
|
- A zustand store holding `GameState`.
|
||||||
- **Derived render state**: `game state + surface routes => map of piece id to
|
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||||
`{ surface, route, candidate, index, stackSize }``, per enabled surface.
|
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||||
Computed with a selector/memo so the render list is stable.
|
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||||
- **Assumption**: each piece id is unique within a path (documented in
|
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||||
`bgm-tabletop.md`); the render map is keyed by piece id.
|
- **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`) ✅
|
### 4. Setup seeding (`setup.ts`) ✅
|
||||||
|
|
||||||
- `SetupLoader`: side-effect-only component that seeds the store from a
|
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||||
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
|
`Setup` — enables its `surfaces` (or all when omitted) and applies its
|
||||||
`setup` paths.
|
ordered `setup` placements (each moves its `parts` to a `path`).
|
||||||
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
- `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).
|
game-state init concern, so it lives here).
|
||||||
|
|
||||||
### 5. Surface mounting (`mount.ts`) ✅
|
### 5. Surface mounting (`mount.ts`) ✅
|
||||||
@@ -119,7 +127,7 @@ interface GameState {
|
|||||||
### 7. Stacking (`stacking.ts`) ✅
|
### 7. Stacking (`stacking.ts`) ✅
|
||||||
|
|
||||||
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
|
- `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`/
|
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||||
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
||||||
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
|
- `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`
|
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||||
from the library (work item 2), proving it end-to-end.
|
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)
|
## Open decisions (defaults in bold)
|
||||||
|
|
||||||
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
- **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
|
`textureUrl + color + roughness`. drei already caches textures by URL
|
||||||
globally, so sharing the material on top avoids per-object material
|
globally, so sharing the material on top avoids per-object material
|
||||||
allocation for tiles/tokens with the same image.
|
allocation for tiles/tokens with the same image.
|
||||||
- **Cards are the exception:** each card clones its texture for sprite UVs, so
|
- **Cards:** the face/back textures are shared (drei caches them by URL) and
|
||||||
its face material cannot be shared — but its geometry still can (same card
|
the sprite cell is selected via a per-material UV transform injected into the
|
||||||
size).
|
shader (`cardMaterial.ts`), so cards share texture, shader, and geometry —
|
||||||
|
only the material uniforms differ. Materials are cached per card id + tint.
|
||||||
- Dispose shared resources on page unmount, or accept a module-level cache for
|
- Dispose shared resources on page unmount, or accept a module-level cache for
|
||||||
the session (see Open decisions).
|
the session (see Open decisions).
|
||||||
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
||||||
> order. For the system's architecture and dependency graph, see
|
> order. For the system's architecture and dependency graph, see
|
||||||
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
|
> [`../architecture.md`](../architecture.md). For the rationale behind key decisions,
|
||||||
> see [`decisions.md`](./decisions.md).
|
> see [`../decisions.md`](../decisions.md).
|
||||||
|
|
||||||
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
||||||
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
||||||
@@ -55,7 +55,16 @@ tts-workshop/
|
|||||||
├── .npmrc
|
├── .npmrc
|
||||||
├── .env.example # STEAM_API_KEY, PORT
|
├── .env.example # STEAM_API_KEY, PORT
|
||||||
├── docs/
|
├── 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/
|
├── apps/
|
||||||
│ ├── proxy/
|
│ ├── proxy/
|
||||||
│ │ ├── package.json
|
│ │ ├── 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
|
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
||||||
standard `45×45` mm square.
|
standard `45×45` mm square.
|
||||||
|
|
||||||
```yaml file=carcassonne.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: carcassonne
|
id: carcassonne
|
||||||
title: Carcassonne
|
title: Carcassonne
|
||||||
designer: Klaus-Jürgen Wrede
|
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
|
face image and a uniform size; the `$variants` CSV expands them into the 24
|
||||||
tile parts.
|
tile parts.
|
||||||
|
|
||||||
```yaml file=tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
face: ./20AE_Base_Game_C2_Tile_A.png
|
face: ./20AE_Base_Game_C2_Tile_A.png
|
||||||
size: [45, 45, 3]
|
size: [45, 45, 3]
|
||||||
fillet: 1
|
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
|
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
||||||
so tiles sit edge to edge.
|
so tiles sit edge to edge.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: board
|
id: board
|
||||||
role: surface
|
|
||||||
size: [600, 600]
|
size: [600, 600]
|
||||||
layout:
|
layout:
|
||||||
- route: /draw
|
- 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
|
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.
|
every tile of that type), then seed the grid with a few opening tiles.
|
||||||
|
|
||||||
```yaml file=main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#board
|
- board#board
|
||||||
setup:
|
setup:
|
||||||
/draw: carcassonne:tile
|
- path: /draw
|
||||||
/grid/5/5: carcassonne:tile#a
|
parts: carcassonne:tile
|
||||||
/grid/5/6: carcassonne:tile#b
|
- path: /grid/5/5
|
||||||
/grid/6/5: carcassonne:tile#c
|
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
|
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
||||||
generate a full deck from a single part definition.
|
generate a full deck from a single part definition.
|
||||||
|
|
||||||
```yaml file=poker.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: poker
|
id: poker
|
||||||
title: Poker
|
title: Poker
|
||||||
designer: Public Domain
|
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
|
4 suits down. All cards share the same back from the `4×1` back sheet
|
||||||
(`back-4x1.png`).
|
(`back-4x1.png`).
|
||||||
|
|
||||||
```yaml file=cards.yaml
|
```yaml role=part.card
|
||||||
role: part
|
|
||||||
type: card
|
|
||||||
face: ./cards-13x4.jpg
|
face: ./cards-13x4.jpg
|
||||||
back: ./back-4x1.png
|
back: ./back-4x1.png
|
||||||
size: [63, 88, 0.3]
|
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
|
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.
|
middle. The deck pile fans its stacked cards along a curve.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: poker
|
id: poker
|
||||||
role: surface
|
|
||||||
size: [600, 400]
|
size: [600, 400]
|
||||||
layout:
|
layout:
|
||||||
- route: /deck
|
- route: /deck
|
||||||
@@ -125,16 +120,43 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
|
Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
|
||||||
that type), then flip a flop onto the community slots.
|
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
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/deck: poker:card
|
- path: /deck
|
||||||
/community/0: poker:card#as
|
parts: poker:card
|
||||||
/community/1: poker:card#kh
|
facing: back
|
||||||
/community/2: poker:card#7d
|
- 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",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
A tiny example game used to exercise the bgm loader.
|
A tiny example game used to exercise the bgm loader.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,9 +24,7 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: grain
|
id: grain
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [0, 0, 5, 2]
|
faceCrop: [0, 0, 5, 2]
|
||||||
@@ -41,10 +37,8 @@ fillet: 2
|
|||||||
|
|
||||||
## Board
|
## Board
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: table
|
kind: table
|
||||||
@@ -63,10 +57,8 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/player.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: player
|
id: player
|
||||||
role: surface
|
|
||||||
size: [200, 200]
|
size: [200, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: child
|
kind: child
|
||||||
@@ -79,14 +71,14 @@ layout:
|
|||||||
$variants: ./hand.csv
|
$variants: ./hand.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/hand.csv
|
```csv file=hand.csv
|
||||||
slot,x,y,rotation
|
slot,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,0,0,0
|
0,0,0,0
|
||||||
1,0,20,0
|
1,0,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
@@ -95,14 +87,14 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#harbor
|
- board#harbor
|
||||||
- board#player
|
- board#player
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:token#wood
|
- path: /dock/0
|
||||||
/deck: harbor:token#grain
|
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
|
A second tiny example game, used to exercise the loader's collection of
|
||||||
multiple packages.
|
multiple packages.
|
||||||
|
|
||||||
```yaml file=azul.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: azul
|
id: azul
|
||||||
title: Azul
|
title: Azul
|
||||||
designer: Michael Kiesling
|
designer: Michael Kiesling
|
||||||
@@ -13,9 +12,7 @@ language: en
|
|||||||
include: ['./**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
id: blue
|
id: blue
|
||||||
face: ./assets/tiles.png
|
face: ./assets/tiles.png
|
||||||
faceCrop: [0, 0, 5, 5]
|
faceCrop: [0, 0, 5, 5]
|
||||||
@@ -23,10 +20,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 1
|
fillet: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: azul
|
id: azul
|
||||||
role: surface
|
|
||||||
size: [400, 300]
|
size: [400, 300]
|
||||||
layout:
|
layout:
|
||||||
- route: /factory/:n
|
- route: /factory/:n
|
||||||
@@ -34,7 +29,7 @@ layout:
|
|||||||
$variants: ./factories.csv
|
$variants: ./factories.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/factories.csv
|
```csv file=factories.csv
|
||||||
n,x,y,rotation
|
n,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,-150,0,0
|
0,-150,0,0
|
||||||
@@ -43,10 +38,9 @@ string,number,number,number
|
|||||||
3,150,0,0
|
3,150,0,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
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
|
A tiny example game used to exercise the bgm loader end-to-end through a real
|
||||||
vite build.
|
vite build.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
@@ -13,9 +12,7 @@ language: en
|
|||||||
include: ['./**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,10 +23,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
layout:
|
layout:
|
||||||
- route: /dock/:seat
|
- route: /dock/:seat
|
||||||
@@ -41,18 +36,18 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
1,40,20,0
|
1,40,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:token#wood
|
- path: /dock/0
|
||||||
/deck: harbor:token#grain
|
parts: harbor:token#wood
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:token#grain
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,89 +1,134 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadDefs, collectPackages } from './collect.js';
|
import { loadDefs, collectPackages } from "./collect.js";
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
|
const fixtureRoot = path.resolve(
|
||||||
const multiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'vite-build', 'games');
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"__fixtures__",
|
||||||
|
"harbor",
|
||||||
|
);
|
||||||
|
const multiRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"__fixtures__",
|
||||||
|
"vite-build",
|
||||||
|
"games",
|
||||||
|
);
|
||||||
|
|
||||||
describe('collectPackages', () => {
|
describe("collectPackages", () => {
|
||||||
it('collects the harbor package from markdown code blocks', () => {
|
it("collects the harbor package from markdown code blocks", () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs("", fixtureRoot);
|
||||||
const packages = collectPackages(defMap, fixtureRoot);
|
const packages = collectPackages(defMap, fixtureRoot);
|
||||||
|
|
||||||
expect(packages).toHaveLength(1);
|
expect(packages).toHaveLength(1);
|
||||||
const harbor = packages[0]!;
|
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.
|
// Two tokens from two yaml blocks sharing a `role=part.token` name.
|
||||||
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
expect([...harbor.parts.keys()].sort()).toEqual([
|
||||||
const wood = harbor.parts.get('token#wood')!;
|
"token#grain",
|
||||||
|
"token#wood",
|
||||||
|
]);
|
||||||
|
const wood = harbor.parts.get("token#wood")!;
|
||||||
expect(wood).toMatchObject({
|
expect(wood).toMatchObject({
|
||||||
type: 'token',
|
type: "token",
|
||||||
id: 'wood',
|
id: "wood",
|
||||||
size: [20, 20, 3],
|
size: [20, 20, 3],
|
||||||
fillet: 2,
|
fillet: 2,
|
||||||
});
|
});
|
||||||
expect(wood.face).toBe('./assets/tokens.png');
|
expect(wood.face).toBe("./assets/tokens.png");
|
||||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||||
// Relative assets resolve against the source file's directory. The fixture
|
// Relative assets resolve against the markdown file's directory. The
|
||||||
// markdown sits at the games root, so the virtual file is `parts/tokens.yaml`.
|
// fixture markdown sits at the games root, so baseUrl is empty.
|
||||||
expect(wood.baseUrl).toBe('parts/');
|
expect(wood.baseUrl).toBe("");
|
||||||
|
|
||||||
// Two surfaces: the table board and its child player board.
|
// Two surfaces: the table board and its child player board.
|
||||||
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
expect([...harbor.surfaces.keys()].sort()).toEqual([
|
||||||
const board = harbor.surfaces.get('board#harbor')!;
|
"board#harbor",
|
||||||
|
"board#player",
|
||||||
|
]);
|
||||||
|
const board = harbor.surfaces.get("board#harbor")!;
|
||||||
expect(board.size).toEqual([300, 200]);
|
expect(board.size).toEqual([300, 200]);
|
||||||
expect(board.mount).toEqual({ kind: 'table', x: 0, y: 0, rotation: 0 });
|
expect(board.mount).toEqual({ kind: "table", x: 0, y: 0, rotation: 0 });
|
||||||
expect(board.children).toEqual(['board#player']);
|
expect(board.children).toEqual(["board#player"]);
|
||||||
expect(board.layout).toHaveLength(2);
|
expect(board.layout).toHaveLength(2);
|
||||||
const dock = board.layout[0]!;
|
const dock = board.layout[0]!;
|
||||||
expect(dock.route).toBe('/dock/:seat');
|
expect(dock.route).toBe("/dock/:seat");
|
||||||
expect(dock.candidates).toEqual([
|
expect(dock.candidates).toEqual([
|
||||||
{ seat: '0', x: 40, y: 0, rotation: 0 },
|
{ seat: "0", x: 40, y: 0, rotation: 0 },
|
||||||
{ seat: '1', x: 40, y: 20, rotation: 0 },
|
{ seat: "1", x: 40, y: 20, rotation: 0 },
|
||||||
]);
|
]);
|
||||||
const deck = board.layout[1]!;
|
const deck = board.layout[1]!;
|
||||||
expect(deck.route).toBe('/deck');
|
expect(deck.route).toBe("/deck");
|
||||||
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
|
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
|
||||||
|
|
||||||
const player = harbor.surfaces.get('board#player')!;
|
const player = harbor.surfaces.get("board#player")!;
|
||||||
expect(player.mount).toEqual({ kind: 'child', x: 100, y: 50, rotation: 0 });
|
expect(player.mount).toEqual({ kind: "child", x: 100, y: 50, rotation: 0 });
|
||||||
expect(player.layout).toHaveLength(1);
|
expect(player.layout).toHaveLength(1);
|
||||||
|
|
||||||
// One setup, declaring the enabled surfaces.
|
// One setup, declaring the enabled surfaces.
|
||||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
|
||||||
const setup = harbor.setups.get('game#main')!;
|
const setup = harbor.setups.get("game#main")!;
|
||||||
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
|
expect(setup.surfaces).toEqual(["board#harbor", "board#player"]);
|
||||||
expect(setup.setup).toEqual({
|
expect(setup.setup).toEqual([
|
||||||
'/dock/0': 'harbor:token#wood',
|
{ path: "/dock/0", parts: "harbor:token#wood" },
|
||||||
'/deck': 'harbor:token#grain',
|
{ 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`
|
// Two packages share a games root. Each uses the default `./**/*.yaml`
|
||||||
// include, which must resolve relative to its own folder so neither
|
// include, which must resolve relative to its own folder so neither
|
||||||
// absorbs the other's defs (both define a `game#main` setup).
|
// absorbs the other's defs (both define a `game#main` setup).
|
||||||
const defMap = loadDefs('', multiRoot);
|
const defMap = loadDefs("", multiRoot);
|
||||||
const packages = collectPackages(defMap, multiRoot);
|
const packages = collectPackages(defMap, multiRoot);
|
||||||
|
|
||||||
expect(packages).toHaveLength(2);
|
expect(packages).toHaveLength(2);
|
||||||
const azul = packages.find((p) => p.meta.id === 'azul')!;
|
const azul = packages.find((p) => p.meta.id === "azul")!;
|
||||||
const harbor = packages.find((p) => p.meta.id === 'harbor')!;
|
const harbor = packages.find((p) => p.meta.id === "harbor")!;
|
||||||
|
|
||||||
expect([...azul.parts.keys()]).toEqual(['tile#blue']);
|
expect([...azul.parts.keys()]).toEqual(["tile#blue"]);
|
||||||
expect([...azul.setups.keys()]).toEqual(['game#main']);
|
expect([...azul.setups.keys()]).toEqual(["game#main"]);
|
||||||
expect([...harbor.parts.keys()]).toEqual(['token#wood']);
|
expect([...harbor.parts.keys()]).toEqual(["token#wood"]);
|
||||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on a duplicate type#id', () => {
|
it("throws on a duplicate type#id", () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs("", fixtureRoot);
|
||||||
// Inject a duplicate part into the map under a new file name.
|
// 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)!;
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
|
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
|
||||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
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,
|
* their `include` patterns, and assembles the package's parts, surfaces,
|
||||||
* and setups.
|
* 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 * as path from "node:path";
|
||||||
import picomatch from 'picomatch';
|
import picomatch from "picomatch";
|
||||||
import { collectVirtualFiles } from './markdown.js';
|
import { collectVirtualFiles } from "./markdown.js";
|
||||||
import { parseDefText, readDefFiles } from './parse.js';
|
import { parseDefText, readDefFiles } from "./parse.js";
|
||||||
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
|
import {
|
||||||
import { expandVariants } from './variants.js';
|
validateDialog,
|
||||||
|
validatePackage,
|
||||||
|
validatePart,
|
||||||
|
validateSetup,
|
||||||
|
validateSurface,
|
||||||
|
} from "./schemas.js";
|
||||||
|
import { expandVariants } from "./variants.js";
|
||||||
import {
|
import {
|
||||||
BgmError,
|
BgmError,
|
||||||
|
ROLES,
|
||||||
type DefFile,
|
type DefFile,
|
||||||
|
type Dialog,
|
||||||
type ParsedDef,
|
type ParsedDef,
|
||||||
type Package,
|
type Package,
|
||||||
type PackageDef,
|
type PackageDef,
|
||||||
@@ -29,9 +37,7 @@ import {
|
|||||||
type Role,
|
type Role,
|
||||||
type Setup,
|
type Setup,
|
||||||
type Surface,
|
type Surface,
|
||||||
} from './types.js';
|
} from "./types.js";
|
||||||
|
|
||||||
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
|
|
||||||
|
|
||||||
/** Every definition parsed from a def file, keyed by its path-style name. */
|
/** Every definition parsed from a def file, keyed by its path-style name. */
|
||||||
export interface DefMap {
|
export interface DefMap {
|
||||||
@@ -53,7 +59,7 @@ export function loadDefs(root: string, rootDir: string): DefMap {
|
|||||||
const others: DefFile[] = [];
|
const others: DefFile[] = [];
|
||||||
|
|
||||||
for (const file of realFiles) {
|
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);
|
else others.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +93,12 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
|||||||
for (const [file, defs] of defMap.defs) {
|
for (const [file, defs] of defMap.defs) {
|
||||||
const list: ParsedDef[] = [];
|
const list: ParsedDef[] = [];
|
||||||
for (const def of defs) {
|
for (const def of defs) {
|
||||||
const role = def.value['role'];
|
const role = def.value["role"];
|
||||||
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
|
if (
|
||||||
|
role !== undefined &&
|
||||||
|
typeof role === "string" &&
|
||||||
|
ROLES.has(role as Role)
|
||||||
|
) {
|
||||||
list.push(def);
|
list.push(def);
|
||||||
byRole.set(file, list);
|
byRole.set(file, list);
|
||||||
}
|
}
|
||||||
@@ -98,10 +108,10 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
|||||||
const accs: PackageAcc[] = [];
|
const accs: PackageAcc[] = [];
|
||||||
for (const [file, defs] of byRole) {
|
for (const [file, defs] of byRole) {
|
||||||
for (const def of defs) {
|
for (const def of defs) {
|
||||||
const role = def.value['role'] as Role;
|
const role = def.value["role"] as Role;
|
||||||
if (role === 'package') {
|
if (role === "package") {
|
||||||
const pkg = asPackage(def, file);
|
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));
|
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +130,7 @@ class PackageAcc {
|
|||||||
readonly parts = new Map<string, Part>();
|
readonly parts = new Map<string, Part>();
|
||||||
readonly surfaces = new Map<string, Surface>();
|
readonly surfaces = new Map<string, Surface>();
|
||||||
readonly setups = new Map<string, Setup>();
|
readonly setups = new Map<string, Setup>();
|
||||||
|
readonly dialogs = new Map<string, Dialog>();
|
||||||
readonly byRole = new Map<string, string[]>();
|
readonly byRole = new Map<string, string[]>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -131,23 +142,37 @@ class PackageAcc {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
collect() {
|
collect() {
|
||||||
const include = this.pkg.include ?? ['./**/*.yaml'];
|
const include = this.pkg.include ?? ["./**/*.yaml"];
|
||||||
const names = this.expandIncludes(include);
|
const names = this.expandIncludes(include);
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const fileDefs = this.defs.defs.get(name);
|
const fileDefs = this.defs.defs.get(name);
|
||||||
if (!fileDefs) continue;
|
if (!fileDefs) continue;
|
||||||
for (const def of fileDefs) {
|
for (const def of fileDefs) {
|
||||||
const role = def.value['role'];
|
const role = def.value["role"];
|
||||||
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
|
if (
|
||||||
|
typeof role !== "string" ||
|
||||||
|
!ROLES.has(role as Role) ||
|
||||||
|
role === "package"
|
||||||
|
)
|
||||||
|
continue;
|
||||||
this.add(role as Role, def, name);
|
this.add(role as Role, def, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expand `$variants` on a def object into a list of concrete objects. */
|
/** 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>[] {
|
private expand(
|
||||||
if (!('$variants' in obj)) return [obj];
|
obj: Record<string, unknown>,
|
||||||
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
|
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;
|
const { $variants: _v, ...base } = obj;
|
||||||
return rows.map((row) => ({ ...base, ...row }));
|
return rows.map((row) => ({ ...base, ...row }));
|
||||||
}
|
}
|
||||||
@@ -162,9 +187,15 @@ class PackageAcc {
|
|||||||
// patterns are prefixed to match.
|
// patterns are prefixed to match.
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const pattern of patterns) {
|
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
|
? 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 });
|
const matcher = picomatch(resolved, { dot: true });
|
||||||
for (const name of this.defs.defs.keys()) {
|
for (const name of this.defs.defs.keys()) {
|
||||||
if (matcher(name)) names.add(name);
|
if (matcher(name)) names.add(name);
|
||||||
@@ -174,11 +205,19 @@ class PackageAcc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private add(role: Role, def: ParsedDef, fileName: string) {
|
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) {
|
for (const obj of expanded) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'part': {
|
case "part": {
|
||||||
const part = asPart(obj, fileName);
|
const part = asPart(obj, def.baseDir ?? "");
|
||||||
const key = `${part.type}#${part.id}`;
|
const key = `${part.type}#${part.id}`;
|
||||||
if (this.parts.has(key)) {
|
if (this.parts.has(key)) {
|
||||||
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
||||||
@@ -186,8 +225,8 @@ class PackageAcc {
|
|||||||
this.parts.set(key, part);
|
this.parts.set(key, part);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'surface': {
|
case "surface": {
|
||||||
const surface = asSurface(obj, fileName, this.defs.files);
|
const surface = asSurface(obj, def.baseDir ?? "", this.defs.files);
|
||||||
const key = `${surface.type}#${surface.id}`;
|
const key = `${surface.type}#${surface.id}`;
|
||||||
if (this.surfaces.has(key)) {
|
if (this.surfaces.has(key)) {
|
||||||
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
||||||
@@ -195,7 +234,7 @@ class PackageAcc {
|
|||||||
this.surfaces.set(key, surface);
|
this.surfaces.set(key, surface);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'setup': {
|
case "setup": {
|
||||||
const setup = asSetup(obj, fileName);
|
const setup = asSetup(obj, fileName);
|
||||||
const key = `${setup.type}#${setup.id}`;
|
const key = `${setup.type}#${setup.id}`;
|
||||||
if (this.setups.has(key)) {
|
if (this.setups.has(key)) {
|
||||||
@@ -204,12 +243,27 @@ class PackageAcc {
|
|||||||
this.setups.set(key, setup);
|
this.setups.set(key, setup);
|
||||||
break;
|
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 {
|
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 {
|
try {
|
||||||
const part = validatePart(obj) as unknown as Part;
|
const part = validatePart(obj) as unknown as Part;
|
||||||
// Resolve relative asset paths against the directory of the source file
|
// Resolve relative asset paths against the directory of the source file
|
||||||
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
// (path-style name relative to the games root). For a code block this is
|
||||||
// Real files may carry a leading slash from an empty root; strip it.
|
// the markdown file's directory; for a real file, its own directory.
|
||||||
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
const dir = baseDir.replace(/^\/+/, "");
|
||||||
part.baseUrl = dir ? `${dir}/` : '';
|
part.baseUrl = dir ? `${dir}/` : "";
|
||||||
return part;
|
return part;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSurface(
|
function asSurface(
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
source: string,
|
baseDir: string,
|
||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
): Surface {
|
): Surface {
|
||||||
const value: Record<string, unknown> = { ...obj };
|
const value: Record<string, unknown> = { ...obj };
|
||||||
delete value['role'];
|
delete value["role"];
|
||||||
|
|
||||||
// Expand `candidates.$variants` on each route into a concrete array.
|
// Expand `candidates.$variants` on each route into a concrete array.
|
||||||
if (Array.isArray(value['layout'])) {
|
if (Array.isArray(value["layout"])) {
|
||||||
value['layout'] = value['layout'].map((route) => {
|
value["layout"] = value["layout"].map((route) => {
|
||||||
if (typeof route !== 'object' || route === null) return route;
|
if (typeof route !== "object" || route === null) return route;
|
||||||
const r = route as Record<string, unknown>;
|
const r = route as Record<string, unknown>;
|
||||||
const cand = r['candidates'];
|
const cand = r["candidates"];
|
||||||
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
if (
|
||||||
const rows = expandVariants(cand['$variants'], source, defs, source);
|
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>;
|
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
||||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||||
}
|
}
|
||||||
@@ -266,13 +336,13 @@ function asSurface(
|
|||||||
try {
|
try {
|
||||||
return validateSurface(value) as unknown as Surface;
|
return validateSurface(value) as unknown as Surface;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, baseDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
||||||
const value: Record<string, unknown> = { ...obj };
|
const value: Record<string, unknown> = { ...obj };
|
||||||
delete value['role'];
|
delete value["role"];
|
||||||
try {
|
try {
|
||||||
return validateSetup(value) as unknown as Setup;
|
return validateSetup(value) as unknown as Setup;
|
||||||
} catch (err) {
|
} 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. */
|
/** Wrap a zod error with the source location. */
|
||||||
function wrapZod(err: unknown, source: string): BgmError {
|
function wrapZod(err: unknown, source: string): BgmError {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { scanMarkdown } from './markdown.js';
|
import { scanMarkdown } from './markdown.js';
|
||||||
|
|
||||||
describe('scanMarkdown', () => {
|
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 = [
|
const md = [
|
||||||
'# Title',
|
'# Title',
|
||||||
'',
|
'',
|
||||||
'```yaml file=parts/cargo.yaml',
|
'```yaml role=part.cargo',
|
||||||
'role: part',
|
'id: wood',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'text after',
|
'text after',
|
||||||
@@ -17,26 +17,70 @@ describe('scanMarkdown', () => {
|
|||||||
|
|
||||||
expect(fences).toHaveLength(1);
|
expect(fences).toHaveLength(1);
|
||||||
expect(fences[0]).toMatchObject({
|
expect(fences[0]).toMatchObject({
|
||||||
info: 'yaml file=parts/cargo.yaml',
|
info: 'yaml role=part.cargo',
|
||||||
content: 'role: part',
|
content: 'id: wood',
|
||||||
startLine: 3,
|
startLine: 3,
|
||||||
endLine: 5,
|
endLine: 5,
|
||||||
});
|
});
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0]).toMatchObject({
|
expect(files[0]).toMatchObject({
|
||||||
name: 'harbor/parts/cargo.yaml',
|
name: 'harbor/part.cargo.yaml',
|
||||||
kind: 'yaml',
|
kind: 'yaml',
|
||||||
text: 'role: part',
|
text: 'id: wood',
|
||||||
source: 'harbor/harbor.md:3-5',
|
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 md = '```yaml\nrole: part\n```';
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(0);
|
||||||
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
|
});
|
||||||
expect(files[0]!.kind).toBe('yaml');
|
|
||||||
|
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', () => {
|
it('ignores non-definition languages', () => {
|
||||||
@@ -52,23 +96,15 @@ describe('scanMarkdown', () => {
|
|||||||
expect(files).toHaveLength(0);
|
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', () => {
|
it('tracks line numbers across multiple blocks', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'```yaml file=a.yaml',
|
'```yaml role=part.a',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'```yaml file=b.yaml',
|
'```yaml role=part.b',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
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.
|
* Extract virtual definition files from markdown code blocks.
|
||||||
*
|
*
|
||||||
* Each fenced code block is a virtual definition file:
|
* Each fenced code block is a virtual definition file. A block is a
|
||||||
* - With a `file=` segment in its info string, named relative to the
|
* definition only when its info string declares a `role=` (or a `file=`):
|
||||||
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
|
* - With `role=part.cargo`, named `part.cargo.yaml` (the `role.type.lang`
|
||||||
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
|
* form), discoverable by the default include pattern (all yaml in the
|
||||||
* block is discoverable by the default include pattern (all yaml in the
|
* same and sub folders).
|
||||||
* same and sub folders). Identical blocks dedupe to the same hash.
|
* - 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
|
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
||||||
* virtual file.
|
* virtual file.
|
||||||
*/
|
*/
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { posix } from 'node:path';
|
import { posix } from 'node:path';
|
||||||
import { marked } from 'marked';
|
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. */
|
/** The languages that count as definition files; others are ignored. */
|
||||||
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
||||||
@@ -25,7 +25,7 @@ export interface Fence {
|
|||||||
startLine: number;
|
startLine: number;
|
||||||
/** Line number (1-based) of the closing fence. */
|
/** Line number (1-based) of the closing fence. */
|
||||||
endLine: number;
|
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;
|
info: string;
|
||||||
/** The code block's content (without the fences). */
|
/** The code block's content (without the fences). */
|
||||||
content: string;
|
content: string;
|
||||||
@@ -60,13 +60,17 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
|
|
||||||
fences.push({ startLine, endLine, info, content: token.text });
|
fences.push({ startLine, endLine, info, content: token.text });
|
||||||
|
|
||||||
const name = parseInfo(info);
|
const parsed = parseInfo(info, `${sourcePath}:${startLine}`);
|
||||||
if (name) {
|
if (parsed) {
|
||||||
files.push({
|
files.push({
|
||||||
name: posix.join(posix.dirname(sourcePath), name),
|
name: posix.join(posix.dirname(sourcePath), parsed.name),
|
||||||
text: token.text,
|
text: token.text,
|
||||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
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 };
|
return { fences, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ParsedInfo {
|
||||||
* Parse a fence's info string for a `file=` segment and derive the virtual
|
name: string;
|
||||||
* file name. Blocks without `file=` are auto-named from their content hash.
|
kind: DefFile['kind'];
|
||||||
*/
|
role?: RoleMeta;
|
||||||
function parseInfo(info: string): string | null {
|
}
|
||||||
const fileMatch = /file=(\S+)/.exec(info);
|
|
||||||
if (fileMatch) return fileMatch[1]!;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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];
|
const lang = info.split(/\s+/)[0];
|
||||||
if (!lang || !DEF_LANGS.has(lang)) return null;
|
const fileMatch = /file=(\S+)/.exec(info);
|
||||||
return `./${hash(info)}.yaml`;
|
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. */
|
/** Derive the def file type from its name's extension. */
|
||||||
@@ -96,9 +137,21 @@ function kindOf(name: string): DefFile['kind'] {
|
|||||||
return 'yaml';
|
return 'yaml';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stable content hash for auto-named blocks. */
|
/** The file extension for a definition language tag. */
|
||||||
function hash(text: string): string {
|
function extOf(lang: string): string {
|
||||||
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
|
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`. */
|
/** 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.
|
* 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`
|
* Multiple blocks may share a name (e.g. several `role=part.token` blocks);
|
||||||
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
|
* each is kept as a separate entry.
|
||||||
* same hash name.
|
|
||||||
*/
|
*/
|
||||||
export type VirtualFiles = Map<string, DefFile[]>;
|
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.
|
* 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
|
* 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.
|
* each object is a separate definition.
|
||||||
*/
|
*/
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { parse as parseToml } from 'smol-toml';
|
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.
|
* 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
|
* @returns the parsed objects; the root object (index `-1`) or the list
|
||||||
* items (index `0..n`)
|
* items (index `0..n`)
|
||||||
*/
|
*/
|
||||||
@@ -33,7 +37,8 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
|
|
||||||
const push = (value: unknown, index: number) => {
|
const push = (value: unknown, index: number) => {
|
||||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
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) {
|
} else if (value !== null) {
|
||||||
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,27 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
return out;
|
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 {
|
function parseText(kind: DefFile['kind'], text: string): unknown {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'json':
|
case 'json':
|
||||||
@@ -92,6 +118,8 @@ export function readDefFiles(dir: string, root: string): DefFile[] {
|
|||||||
text: fs.readFileSync(abs, 'utf8'),
|
text: fs.readFileSync(abs, 'utf8'),
|
||||||
source: abs,
|
source: abs,
|
||||||
kind,
|
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)
|
* These validate the raw definition objects (after `$variants` expansion)
|
||||||
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
|
* 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 { z } from "zod";
|
||||||
import type { PackageDef, Part, Setup, Surface } from './types.js';
|
import type { Dialog, PackageDef, Part, Setup, Surface } from "./types.js";
|
||||||
|
|
||||||
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
||||||
const size = z.tuple([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({
|
const stacking = z.object({
|
||||||
curve: z.string().optional(),
|
curve: z.string().optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
align: z.enum(['start', 'end', 'center']).optional(),
|
align: z.enum(["start", "end", "center"]).optional(),
|
||||||
steps: z.number().optional(),
|
steps: z.number().optional(),
|
||||||
tilt: z.number().optional(),
|
tilt: z.number().optional(),
|
||||||
zStart: z.number().optional(),
|
zStart: z.number().optional(),
|
||||||
@@ -44,7 +44,7 @@ const partSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const surfaceMount = z.object({
|
const surfaceMount = z.object({
|
||||||
kind: z.enum(['table', 'hud', 'child']),
|
kind: z.enum(["table", "hud", "child"]),
|
||||||
x: z.number().optional(),
|
x: z.number().optional(),
|
||||||
y: z.number().optional(),
|
y: z.number().optional(),
|
||||||
rotation: z.number().optional(),
|
rotation: z.number().optional(),
|
||||||
@@ -60,15 +60,43 @@ const surfaceSchema = z.object({
|
|||||||
layout: z.array(route),
|
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({
|
const setupSchema = z.object({
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
surfaces: z.array(z.string()).optional(),
|
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({
|
const packageSchema = z.object({
|
||||||
role: z.literal('package'),
|
role: z.literal("package"),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
designer: 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;
|
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. */
|
/** Validate a raw package definition. */
|
||||||
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
||||||
return packageSchema.parse(value) as unknown as 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
|
* discovered as JSON objects from yaml/json/toml files and from markdown
|
||||||
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
|
* 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. */
|
/** 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
|
* 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. */
|
/** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** `start`, `end`, or `center` of the curve. */
|
/** `start`, `end`, or `center` of the curve. */
|
||||||
align?: 'start' | 'end' | 'center';
|
align?: "start" | "end" | "center";
|
||||||
/** Maximum parts per curve length unit; defaults to `1`. */
|
/** Maximum parts per curve length unit; defaults to `1`. */
|
||||||
steps?: number;
|
steps?: number;
|
||||||
/**
|
/**
|
||||||
@@ -126,7 +126,7 @@ export interface Stacking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** How a surface is mounted. `kind` selects the mount type. */
|
/** 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`
|
* 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[];
|
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 {
|
export interface Setup {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
||||||
surfaces?: string[];
|
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
|
* A raw definition object as written by the author. Definitions can be the
|
||||||
@@ -177,25 +279,29 @@ export interface RawDef {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The four definition roles. */
|
/** The five definition roles. */
|
||||||
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef;
|
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef | DialogDef;
|
||||||
|
|
||||||
export interface PackageDef extends PackageMeta {
|
export interface PackageDef extends PackageMeta {
|
||||||
role: 'package';
|
role: "package";
|
||||||
/** Git-style path patterns of the defs that make up the package. */
|
/** Git-style path patterns of the defs that make up the package. */
|
||||||
include?: string[];
|
include?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartDef extends Part {
|
export interface PartDef extends Part {
|
||||||
role: 'part';
|
role: "part";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurfaceDef extends Surface {
|
export interface SurfaceDef extends Surface {
|
||||||
role: 'surface';
|
role: "surface";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupDef extends Setup {
|
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. */
|
/** 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 location for error messages (real path or `file.md:12-19`). */
|
||||||
source: string;
|
source: string;
|
||||||
/** File type derived from the name's extension. */
|
/** 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). */
|
/** A single parsed definition (one JSON object from a def file). */
|
||||||
@@ -219,6 +333,10 @@ export interface ParsedDef {
|
|||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||||
source: string;
|
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>;
|
surfaces: Map<string, Surface>;
|
||||||
/** All setups by `type#id`. */
|
/** All setups by `type#id`. */
|
||||||
setups: Map<string, Setup>;
|
setups: Map<string, Setup>;
|
||||||
|
/** All dialogs by `type#id`. */
|
||||||
|
dialogs: Map<string, Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,12 +368,17 @@ export interface SerializedPackage {
|
|||||||
surfaces: Record<string, Surface>;
|
surfaces: Record<string, Surface>;
|
||||||
/** All setups by `type#id`. */
|
/** All setups by `type#id`. */
|
||||||
setups: Record<string, Setup>;
|
setups: Record<string, Setup>;
|
||||||
|
/** All dialogs by `type#id`. */
|
||||||
|
dialogs: Record<string, Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Errors during loading, carrying the source location when available. */
|
/** Errors during loading, carrying the source location when available. */
|
||||||
export class BgmError extends Error {
|
export class BgmError extends Error {
|
||||||
constructor(message: string, readonly location?: string) {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly location?: string,
|
||||||
|
) {
|
||||||
super(location ? `${location}: ${message}` : message);
|
super(location ? `${location}: ${message}` : message);
|
||||||
this.name = 'BgmError';
|
this.name = "BgmError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('parseCsvData', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('expandVariants', () => {
|
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');
|
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
||||||
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
||||||
});
|
});
|
||||||
@@ -61,15 +61,30 @@ describe('expandVariants', () => {
|
|||||||
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
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', () => {
|
it('throws when the referenced csv is missing', () => {
|
||||||
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
/CSV not found/,
|
/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(
|
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
|
* The `$variants` directive: parse a CSV into a typed object array and
|
||||||
* extend the original object with each row.
|
* 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
|
* - The CSV's first row is the header, the second row is the type declaration
|
||||||
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
|
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
|
||||||
* the remaining rows are data.
|
* the remaining rows are data.
|
||||||
* - Rows are validated against a schema derived from the type row.
|
* - Rows are validated against a schema derived from the type row.
|
||||||
* - A cell for an array/tuple type uses `;` as the element separator
|
* - A cell for an array/tuple type uses `;` as the element separator
|
||||||
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
||||||
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
|
* - `$variants` can be a single source or an array of them. A source is a
|
||||||
* containing a newline is inline CSV, otherwise it is a path.
|
* file/URL path if its first line ends in `.csv`, otherwise inline CSV.
|
||||||
*
|
*
|
||||||
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
||||||
* this header/schema/data layout and validates each row against a schema
|
* 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.
|
* 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
|
* @param baseName the path-style name of the referencing def file; a path
|
||||||
* value resolves relative to its directory
|
* value resolves relative to its directory
|
||||||
* @param defs the virtual def map, for resolving the path
|
* @param defs the virtual def map, for resolving the path
|
||||||
@@ -86,14 +87,41 @@ export function expandVariants(
|
|||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
source: string,
|
source: string,
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
if (typeof value !== 'string') {
|
const sources = Array.isArray(value) ? value : [value];
|
||||||
throw new BgmError('`$variants` must be a path or inline CSV string', source);
|
if (sources.length === 0) {
|
||||||
|
throw new BgmError('`$variants` array must not be empty', source);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.includes('\n')) {
|
const rows: Record<string, unknown>[] = [];
|
||||||
return parseCsvData(value, source).rows;
|
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
|
* be mistaken for real installed packages. Editing a game definition
|
||||||
* hot-reloads the app via `addWatchFile`.
|
* hot-reloads the app via `addWatchFile`.
|
||||||
*/
|
*/
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { normalizePath, type ModuleNode, type Plugin } from 'vite';
|
import { normalizePath, type ModuleNode, type Plugin } from "vite";
|
||||||
import { collectPackages, loadDefs } from './collect.js';
|
import { collectPackages, loadDefs } from "./collect.js";
|
||||||
import { readDefFiles } from './parse.js';
|
import { readDefFiles } from "./parse.js";
|
||||||
import type { Package, SerializedPackage } from './types.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. */
|
/** 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. */
|
/** Public specifier prefix for a single package module. */
|
||||||
const PACKAGE = 'virtual:bgm/package/';
|
const PACKAGE = "virtual:bgm/package/";
|
||||||
|
|
||||||
export interface BgmOptions {
|
export interface BgmOptions {
|
||||||
/** Absolute path to the games root (e.g. `<repo>/games`). */
|
/** 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 root = normalizePath(options.root);
|
||||||
|
|
||||||
const collect = (): Package[] => {
|
const collect = (): Package[] => {
|
||||||
const defMap = loadDefs('', root);
|
const defMap = loadDefs("", root);
|
||||||
return collectPackages(defMap, root);
|
return collectPackages(defMap, root);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'bgm',
|
name: "bgm",
|
||||||
buildStart() {
|
buildStart() {
|
||||||
// Watch every real definition source under the games root so edits
|
// Watch every real definition source under the games root so edits
|
||||||
// trigger a re-collect. `defMap.files` only holds the virtual code-block
|
// trigger a re-collect. `defMap.files` only holds the virtual code-block
|
||||||
// files plus real non-markdown files; the markdown files themselves are
|
// files plus real non-markdown files; the markdown files themselves are
|
||||||
// consumed for their code blocks and never appear there, so watch the
|
// consumed for their code blocks and never appear there, so watch the
|
||||||
// real files on disk too (markdown and anything else the loader reads).
|
// 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()) {
|
for (const name of defMap.files.keys()) {
|
||||||
this.addWatchFile(path.join(root, name));
|
this.addWatchFile(path.join(root, name));
|
||||||
}
|
}
|
||||||
for (const file of readDefFiles(root, '')) {
|
for (const file of readDefFiles(root, "")) {
|
||||||
this.addWatchFile(file.source);
|
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.
|
// changes, so edits hot-reload instead of requiring a manual refresh.
|
||||||
if (!ctx.file.startsWith(root)) return;
|
if (!ctx.file.startsWith(root)) return;
|
||||||
const invalidated: ModuleNode[] = [];
|
const invalidated: ModuleNode[] = [];
|
||||||
const mod = ctx.server.moduleGraph.getModuleById(VIRTUAL_PREFIX + PACKAGES);
|
const mod = ctx.server.moduleGraph.getModuleById(
|
||||||
|
VIRTUAL_PREFIX + PACKAGES,
|
||||||
|
);
|
||||||
if (mod) {
|
if (mod) {
|
||||||
ctx.server.moduleGraph.invalidateModule(mod);
|
ctx.server.moduleGraph.invalidateModule(mod);
|
||||||
invalidated.push(mod);
|
invalidated.push(mod);
|
||||||
@@ -102,5 +104,6 @@ function toJson(pkg: Package): SerializedPackage {
|
|||||||
parts: Object.fromEntries(pkg.parts),
|
parts: Object.fromEntries(pkg.parts),
|
||||||
surfaces: Object.fromEntries(pkg.surfaces),
|
surfaces: Object.fromEntries(pkg.surfaces),
|
||||||
setups: Object.fromEntries(pkg.setups),
|
setups: Object.fromEntries(pkg.setups),
|
||||||
|
dialogs: Object.fromEntries(pkg.dialogs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
import { bgm } from './vite.js';
|
import { bgm } from "./vite.js";
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
|
const fixtureRoot = path.resolve(
|
||||||
const gamesRoot = path.join(fixtureRoot, 'harbor');
|
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
|
* 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
|
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
|
||||||
* hook to a callable for direct invocation in tests.
|
* 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 {
|
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
|
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
|
||||||
isEntry: false,
|
id,
|
||||||
});
|
undefined,
|
||||||
|
{
|
||||||
|
isEntry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
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. */
|
/** Parse the JSON payload out of an emitted `export default <json>` module. */
|
||||||
function parseModule(code: unknown): unknown {
|
function parseModule(code: unknown): unknown {
|
||||||
expect(String(code).startsWith('export default ')).toBe(true);
|
expect(String(code).startsWith("export default ")).toBe(true);
|
||||||
return JSON.parse(String(code).slice('export default '.length));
|
return JSON.parse(String(code).slice("export default ".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('bgm vite plugin', () => {
|
describe("bgm vite plugin", () => {
|
||||||
it('resolves bgm imports to the virtual module', () => {
|
it("resolves bgm imports to the virtual module", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
expect(resolveId(plugin, 'virtual:bgm/packages')).toBe('\0bgm:virtual:bgm/packages');
|
expect(resolveId(plugin, "virtual:bgm/packages")).toBe(
|
||||||
expect(resolveId(plugin, 'virtual:bgm/package/harbor')).toBe('\0bgm:virtual:bgm/package/harbor');
|
"\0bgm:virtual:bgm/packages",
|
||||||
expect(resolveId(plugin, 'virtual:bgm/package/nope')).toBe('\0bgm:virtual:bgm/package/nope');
|
);
|
||||||
expect(resolveId(plugin, 'other')).toBeUndefined();
|
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 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).toHaveLength(1);
|
||||||
expect(packages[0].meta.id).toBe('harbor');
|
expect(packages[0].meta.id).toBe("harbor");
|
||||||
expect(packages[0].parts).toHaveProperty('token#wood');
|
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 plugin = bgm({ root: gamesRoot });
|
||||||
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
|
const pkg = parseModule(
|
||||||
expect(pkg.meta.id).toBe('harbor');
|
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
|
||||||
expect(pkg.parts).toHaveProperty('token#wood');
|
) as Record<string, any>;
|
||||||
expect(pkg.surfaces).toHaveProperty('board#harbor');
|
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 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
|
// The emitted shape must be JSON-serializable: plain objects keyed by
|
||||||
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`).
|
// `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]).not.toBeInstanceOf(Map);
|
||||||
expect(pkg[key]).toEqual(expect.any(Object));
|
expect(pkg[key]).toEqual(expect.any(Object));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consumers read the collections with Object.values / Object.keys.
|
// Consumers read the collections with Object.values / Object.keys.
|
||||||
expect(Object.values(pkg.parts).map((p: any) => p.id).sort()).toEqual(['grain', 'wood']);
|
expect(
|
||||||
expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor', 'board#player']);
|
Object.values(pkg.parts)
|
||||||
expect(Object.keys(pkg.setups)).toEqual(['game#main']);
|
.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 });
|
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 plugin = bgm({ root: gamesRoot });
|
||||||
const watched: string[] = [];
|
const watched: string[] = [];
|
||||||
const context = { addWatchFile: (file: string) => watched.push(file) };
|
const context = { addWatchFile: (file: string) => watched.push(file) };
|
||||||
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
|
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
|
||||||
// the options, so pass a placeholder.
|
// 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
|
// Every def file (real + virtual code blocks) is watched so edits
|
||||||
// trigger a re-collect. The real markdown source must be watched too:
|
// trigger a re-collect. The real markdown source must be watched too:
|
||||||
// `defMap.files` only lists virtual code-block files, so without watching
|
// `defMap.files` only lists virtual code-block files, so without watching
|
||||||
// the on-disk `.md` file the dev server would never notice an edit.
|
// the on-disk `.md` file the dev server would never notice an edit.
|
||||||
expect(watched.length).toBeGreaterThan(0);
|
expect(watched.length).toBeGreaterThan(0);
|
||||||
expect(watched.some((f) => f.endsWith('.yaml'))).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(".csv"))).toBe(true);
|
||||||
expect(watched.some((f) => f.endsWith('.md'))).toBe(true);
|
expect(watched.some((f) => f.endsWith(".md"))).toBe(true);
|
||||||
expect(watched.every((f) => path.isAbsolute(f))).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"]
|
||||||
|
}
|
||||||
@@ -24,3 +24,8 @@ export function resolveAssetUrl(url: string, baseUrl?: string): string {
|
|||||||
export function assetUrl(url: string): string {
|
export function assetUrl(url: string): string {
|
||||||
return `/asset?url=${encodeURIComponent(url)}`;
|
return `/asset?url=${encodeURIComponent(url)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Route a PDF URL through the proxy so it renders inline instead of downloading. */
|
||||||
|
export function pdfUrl(url: string): string {
|
||||||
|
return `/pdf?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { resolveAssetUrl, assetUrl } from './asset.js';
|
export { resolveAssetUrl, assetUrl, pdfUrl } from './asset.js';
|
||||||
export { traceImage } from './trace.js';
|
export { traceImage } from './trace.js';
|
||||||
export type { TraceResult } from '@tts/shared';
|
export type { TraceResult } from '@tts/shared';
|
||||||
@@ -87,6 +87,14 @@ export interface TTSMod {
|
|||||||
ObjectStates: TTSObject[];
|
ObjectStates: TTSObject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A parsed save plus its Workshop metadata, returned by `GET /items/:id`. */
|
||||||
|
export interface ModDetails {
|
||||||
|
mod: TTSMod;
|
||||||
|
/** Present when the metadata could be resolved via the Steam API. */
|
||||||
|
title?: string;
|
||||||
|
previewImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Metadata for a Workshop item, from the Steam Web API. */
|
/** Metadata for a Workshop item, from the Steam Web API. */
|
||||||
export interface WorkshopItem {
|
export interface WorkshopItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -27,17 +27,21 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
console.error('Part viewer error:', error, info.componentStack);
|
console.error('Part viewer error:', error, info.componentStack);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private retry = () => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
return this.props.fallback
|
return this.props.fallback
|
||||||
? this.props.fallback(this.state.error)
|
? this.props.fallback(this.state.error)
|
||||||
: <DefaultFallback error={this.state.error} />;
|
: <DefaultFallback error={this.state.error} onRetry={this.retry} />;
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function DefaultFallback({ error }: { error: Error }) {
|
function DefaultFallback({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||||
return (
|
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">
|
<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>
|
<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}
|
{error.stack}
|
||||||
</pre>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,5 +36,6 @@ role: setup
|
|||||||
type: game
|
type: game
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/deck: harbor:token
|
- path: /deck
|
||||||
|
parts: harbor:token
|
||||||
```
|
```
|
||||||
@@ -24,7 +24,7 @@ const tree = resolveMountTree(
|
|||||||
new Set(Object.keys(seeded.surfaces)),
|
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 expanded = expandSetupValue(pkg as never, 'harbor:token');
|
||||||
export const placementCount = placements.length;
|
export const placementCount = placements.length;
|
||||||
export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0');
|
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,
|
fallbackShape,
|
||||||
traceToShape,
|
traceToShape,
|
||||||
traceToUvBounds,
|
traceToUvBounds,
|
||||||
|
facingTransform,
|
||||||
MM_TO_WORLD,
|
MM_TO_WORLD,
|
||||||
} from './part.js';
|
} from './part.js';
|
||||||
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||||
@@ -24,7 +25,7 @@ export {
|
|||||||
computeSurfacePlacements,
|
computeSurfacePlacements,
|
||||||
placementKey,
|
placementKey,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
export type { GameState, Placement } from './state.js';
|
export type { GameState, Placement, PartState } from './state.js';
|
||||||
|
|
||||||
// Setup seeding.
|
// Setup seeding.
|
||||||
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
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 { describe, expect, it } from "vitest";
|
||||||
import { serializedToPackage } from './package.js';
|
import { serializedToPackage } from "./package.js";
|
||||||
|
|
||||||
describe('serializedToPackage', () => {
|
describe("serializedToPackage", () => {
|
||||||
it('converts plain-object maps to Map instances', () => {
|
it("converts plain-object maps to Map instances", () => {
|
||||||
const serialized = {
|
const serialized = {
|
||||||
meta: { id: 'harbor' },
|
meta: { id: "harbor" },
|
||||||
parts: { 'token#wood': { type: 'token', id: 'wood' } },
|
parts: { "token#wood": { type: "token", id: "wood" } },
|
||||||
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } },
|
surfaces: { "board#harbor": { type: "board", id: "harbor", layout: [] } },
|
||||||
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } },
|
setups: { "game#main": { type: "game", id: "main", setup: [] } },
|
||||||
|
dialogs: {
|
||||||
|
"prompt#insert": { type: "prompt", id: "insert", title: "Insert" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const pkg = serializedToPackage(serialized);
|
const pkg = serializedToPackage(serialized);
|
||||||
expect(pkg.meta.id).toBe('harbor');
|
expect(pkg.meta.id).toBe("harbor");
|
||||||
expect(pkg.parts).toBeInstanceOf(Map);
|
expect(pkg.parts).toBeInstanceOf(Map);
|
||||||
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' });
|
expect(pkg.parts.get("token#wood")).toEqual({ type: "token", id: "wood" });
|
||||||
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' });
|
expect(pkg.surfaces.get("board#harbor")).toMatchObject({ type: "board" });
|
||||||
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' });
|
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
|
* `virtual:bgm/*` get the serialized form; the tabletop components take the
|
||||||
* `Package` form.
|
* `Package` form.
|
||||||
*/
|
*/
|
||||||
import type { Package, SerializedPackage } from '@tts/bgm';
|
import type { Package, SerializedPackage } from "@tts/bgm";
|
||||||
|
|
||||||
export function serializedToPackage(serialized: SerializedPackage): Package {
|
export function serializedToPackage(serialized: SerializedPackage): Package {
|
||||||
return {
|
return {
|
||||||
@@ -12,5 +12,6 @@ export function serializedToPackage(serialized: SerializedPackage): Package {
|
|||||||
parts: new Map(Object.entries(serialized.parts)),
|
parts: new Map(Object.entries(serialized.parts)),
|
||||||
surfaces: new Map(Object.entries(serialized.surfaces)),
|
surfaces: new Map(Object.entries(serialized.surfaces)),
|
||||||
setups: new Map(Object.entries(serialized.setups)),
|
setups: new Map(Object.entries(serialized.setups)),
|
||||||
|
dialogs: new Map(Object.entries(serialized.dialogs)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
fallbackShape,
|
fallbackShape,
|
||||||
traceToShape,
|
traceToShape,
|
||||||
traceToUvBounds,
|
traceToUvBounds,
|
||||||
|
facingTransform,
|
||||||
} from './part.js';
|
} from './part.js';
|
||||||
|
|
||||||
describe('partDimensions', () => {
|
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', () => {
|
describe('spriteUvFromCrop', () => {
|
||||||
it('returns full-image UVs without a crop', () => {
|
it('returns full-image UVs without a crop', () => {
|
||||||
expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
|
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
|
* so they can be unit-tested in a plain node environment (mirroring the web
|
||||||
* app's `cardResolution.ts`).
|
* app's `cardResolution.ts`).
|
||||||
*/
|
*/
|
||||||
import type { Part, Crop } from '@tts/bgm';
|
import type { Part, Crop, Facing } from '@tts/bgm';
|
||||||
import {
|
import {
|
||||||
rectShape,
|
rectShape,
|
||||||
roundedRectShape,
|
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
|
* 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
|
* `[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 face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
const back = useTexture(backUrl ? assetUrl(backUrl) : 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 trace = useTrace(shapeUrl, traceImage);
|
||||||
|
|
||||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import type { Package } from '@tts/bgm';
|
|||||||
import { useStacking } from './stacking.js';
|
import { useStacking } from './stacking.js';
|
||||||
import type { Placement } from './state.js';
|
import type { Placement } from './state.js';
|
||||||
import { PartView } from './partView.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 }) {
|
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`.
|
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
||||||
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
||||||
if (!part) return null;
|
if (!part) return null;
|
||||||
@@ -34,11 +34,18 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
|||||||
const anchorRotation =
|
const anchorRotation =
|
||||||
((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD;
|
((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 (
|
return (
|
||||||
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||||
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
<group position={[-pivot[0], -pivot[1], -pivot[2]]}>
|
||||||
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
<group rotation={[xRotation, tilt * DEG_TO_RAD, 0]}>
|
||||||
<PartView part={part} baseUrl={part.baseUrl} />
|
<PartView part={part} baseUrl={part.baseUrl} />
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,55 +1,118 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Package } from '@tts/bgm';
|
import type { Facing, Package } from "@tts/bgm";
|
||||||
import { expandSetupValue, seedFromSetup } from './setup.js';
|
import { expandSetupValue, seedFromSetup } from "./setup.js";
|
||||||
|
|
||||||
const pkg: Package = {
|
const pkg: Package = {
|
||||||
meta: { id: 'harbor' },
|
meta: { id: "harbor" },
|
||||||
parts: new Map([
|
parts: new Map([
|
||||||
['token#wood', { type: 'token', id: 'wood' }],
|
["token#wood", { type: "token", id: "wood" }],
|
||||||
['token#grain', { type: 'token', id: 'grain' }],
|
["token#grain", { type: "token", id: "grain" }],
|
||||||
['card#fleet', { type: 'card', id: 'fleet' }],
|
["card#fleet", { type: "card", id: "fleet" }],
|
||||||
]),
|
]),
|
||||||
surfaces: new Map([
|
surfaces: new Map([
|
||||||
['board#harbor', { type: 'board', id: 'harbor', layout: [] }],
|
["board#harbor", { type: "board", id: "harbor", layout: [] }],
|
||||||
['hud#hand', { type: 'hud', id: 'hand', layout: [] }],
|
["hud#hand", { type: "hud", id: "hand", layout: [] }],
|
||||||
]),
|
]),
|
||||||
setups: new Map(),
|
setups: new Map(),
|
||||||
|
dialogs: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('expandSetupValue', () => {
|
describe("expandSetupValue", () => {
|
||||||
it('keeps a full part id', () => {
|
it("keeps a full part id", () => {
|
||||||
expect(expandSetupValue(pkg, 'harbor:card#fleet')).toEqual(['harbor:card#fleet']);
|
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',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe("seedFromSetup", () => {
|
||||||
it('enables listed surfaces and places parts', () => {
|
it("enables listed surfaces and places parts", () => {
|
||||||
const setup = {
|
const setup = {
|
||||||
type: 'game',
|
type: "game",
|
||||||
id: 'main',
|
id: "main",
|
||||||
surfaces: ['board#harbor'],
|
surfaces: ["board#harbor"],
|
||||||
setup: { '/deck': 'harbor:card#fleet' },
|
setup: [{ path: "/deck", parts: "harbor:card#fleet" }],
|
||||||
};
|
};
|
||||||
const state = seedFromSetup(pkg, setup);
|
const state = seedFromSetup(pkg, setup);
|
||||||
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
expect(state.surfaces).toEqual({ "board#harbor": true });
|
||||||
expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] });
|
expect(state.parts).toEqual({
|
||||||
|
"harbor:card#fleet": { path: "/deck", index: 0, facing: "face" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables all surfaces when omitted', () => {
|
it("enables all surfaces when omitted", () => {
|
||||||
const setup = { type: 'game', id: 'main', setup: {} };
|
const setup = { type: "game", id: "main", setup: [] };
|
||||||
const state = seedFromSetup(pkg, 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.
|
* `type` (no id) expands to all parts of that type during initialization.
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Package, Setup } from '@tts/bgm';
|
import type { Package, Setup, SetupValue } from '@tts/bgm';
|
||||||
import { useTabletopStore } from './state.js';
|
import { useTabletopStore, type PartState } from './state.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
* 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(
|
export function expandSetupValue(
|
||||||
pkg: Package,
|
pkg: Package,
|
||||||
value: string | string[],
|
value: SetupValue,
|
||||||
): string[] {
|
): string[] {
|
||||||
const values = Array.isArray(value) ? value : [value];
|
const values = Array.isArray(value) ? value : [value];
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -39,7 +39,7 @@ export function expandSetupValue(
|
|||||||
/** Seed the store from a setup. Returns the resulting game state. */
|
/** Seed the store from a setup. Returns the resulting game state. */
|
||||||
export function seedFromSetup(pkg: Package, setup: Setup): {
|
export function seedFromSetup(pkg: Package, setup: Setup): {
|
||||||
surfaces: Record<string, boolean>;
|
surfaces: Record<string, boolean>;
|
||||||
paths: Record<string, string[]>;
|
parts: Record<string, PartState>;
|
||||||
} {
|
} {
|
||||||
const surfaces: Record<string, boolean> = {};
|
const surfaces: Record<string, boolean> = {};
|
||||||
if (setup.surfaces) {
|
if (setup.surfaces) {
|
||||||
@@ -48,11 +48,26 @@ export function seedFromSetup(pkg: Package, setup: Setup): {
|
|||||||
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paths: Record<string, string[]> = {};
|
// Apply placements in order: each moves its parts to its path. A part listed
|
||||||
for (const [path, value] of Object.entries(setup.setup)) {
|
// in a later placement ends up on that placement's path. Then index each
|
||||||
paths[path] = expandSetupValue(pkg, value);
|
// 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 { describe, expect, it } from "vitest";
|
||||||
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
|
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js";
|
||||||
|
|
||||||
describe('parsePath', () => {
|
describe("parsePath", () => {
|
||||||
it('measures a straight line', () => {
|
it("measures a straight line", () => {
|
||||||
const path = parsePath('M 0 0 L 10 0');
|
const path = parsePath("M 0 0 L 10 0");
|
||||||
expect(path.length).toBeCloseTo(10);
|
expect(path.length).toBeCloseTo(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('measures a cubic curve', () => {
|
it("measures a cubic curve", () => {
|
||||||
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
|
const path = parsePath("M 0 0 C 20 -20 40 -20 60 0");
|
||||||
// Longer than the chord (60) but finite.
|
// Longer than the chord (60) but finite.
|
||||||
expect(path.length).toBeGreaterThan(60);
|
expect(path.length).toBeGreaterThan(60);
|
||||||
expect(path.length).toBeLessThan(80);
|
expect(path.length).toBeLessThan(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles relative commands', () => {
|
it("handles relative commands", () => {
|
||||||
const path = parsePath('m 0 0 l 10 0 l 0 10');
|
const path = parsePath("m 0 0 l 10 0 l 0 10");
|
||||||
expect(path.length).toBeCloseTo(20);
|
expect(path.length).toBeCloseTo(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports h/v/z', () => {
|
it("supports h/v/z", () => {
|
||||||
const path = parsePath('M 0 0 H 10 V 10 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).
|
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
|
||||||
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
|
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', () => {
|
describe("pointAt", () => {
|
||||||
it('returns the start at distance 0 and end at full length', () => {
|
it("returns the start at distance 0 and end at full length", () => {
|
||||||
const path = parsePath('M 0 0 L 10 0');
|
const path = parsePath("M 0 0 L 10 0");
|
||||||
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
||||||
const end = pointAt(path, path.length);
|
const end = pointAt(path, path.length);
|
||||||
expect(end.x).toBeCloseTo(10);
|
expect(end.x).toBeCloseTo(10);
|
||||||
expect(end.y).toBeCloseTo(0);
|
expect(end.y).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('interpolates along the path', () => {
|
it("interpolates along the path", () => {
|
||||||
const path = parsePath('M 0 0 L 10 0');
|
const path = parsePath("M 0 0 L 10 0");
|
||||||
const mid = pointAt(path, 5);
|
const mid = pointAt(path, 5);
|
||||||
expect(mid.x).toBeCloseTo(5);
|
expect(mid.x).toBeCloseTo(5);
|
||||||
expect(mid.angle).toBeCloseTo(0);
|
expect(mid.angle).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the tangent angle in degrees', () => {
|
it("returns the tangent angle in degrees", () => {
|
||||||
const path = parsePath('M 0 0 L 10 10');
|
const path = parsePath("M 0 0 L 10 10");
|
||||||
expect(pointAt(path, 5).angle).toBeCloseTo(45);
|
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);
|
expect(pointAt(down, 5).angle).toBeCloseTo(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the tangent angle at the start of the path', () => {
|
it("returns the tangent angle at the start of the path", () => {
|
||||||
const path = parsePath('M 0 0 L 10 10');
|
const path = parsePath("M 0 0 L 10 10");
|
||||||
expect(pointAt(path, 0).angle).toBeCloseTo(45);
|
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', () => {
|
describe("stackingOffset", () => {
|
||||||
it('defaults to a 1° tilt without a curve', () => {
|
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(undefined, 0, 3)).toEqual({
|
||||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
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', () => {
|
it("spreads parts evenly along a straight curve", () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
|
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3);
|
||||||
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
||||||
expect(offset.x).toBeCloseTo(50);
|
expect(offset.x).toBeCloseTo(50);
|
||||||
expect(offset.y).toBeCloseTo(0);
|
expect(offset.y).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aligns to center', () => {
|
it("aligns to center", () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
|
const offset = stackingOffset(
|
||||||
|
{ curve: "M 0 0 L 100 0", align: "center" },
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
);
|
||||||
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
||||||
expect(offset.x).toBeCloseTo(0);
|
expect(offset.x).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aligns to end', () => {
|
it("aligns to end", () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
|
const offset = stackingOffset(
|
||||||
|
{ curve: "M 0 0 L 100 0", align: "end" },
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
);
|
||||||
// start = 100 - 100 = 0; part 2 at 100.
|
// start = 100 - 100 = 0; part 2 at 100.
|
||||||
expect(offset.x).toBeCloseTo(100);
|
expect(offset.x).toBeCloseTo(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respects a positive limit (first n)', () => {
|
it("respects a positive limit (first n)", () => {
|
||||||
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);
|
||||||
// Part 2 is beyond the first 2 shown -> not placed.
|
// Part 2 is beyond the first 2 shown -> not placed.
|
||||||
expect(offset).toBe(NO_OFFSET);
|
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.
|
// 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);
|
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.
|
// 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);
|
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);
|
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
||||||
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tilts every part the same amount along the curve', () => {
|
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);
|
const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3);
|
||||||
expect(offset.x).toBeCloseTo(50);
|
expect(offset.x).toBeCloseTo(50);
|
||||||
expect(offset.tilt).toBeCloseTo(0.1);
|
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.
|
// 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 }, 2, 4)).toBe(NO_OFFSET);
|
||||||
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
|
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.
|
// 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 first = stackingOffset(
|
||||||
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
|
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||||
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
|
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(first.z).toBeCloseTo(0);
|
||||||
expect(mid.z).toBeCloseTo(20);
|
expect(mid.z).toBeCloseTo(20);
|
||||||
expect(last.z).toBeCloseTo(40);
|
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);
|
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
|
* 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
|
* stack, compute the offset/rotation to apply. Parts are spread along an SVG
|
||||||
|
|||||||
@@ -1,166 +1,329 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Package, Surface } from '@tts/bgm';
|
import type { Facing, Package, Surface } from "@tts/bgm";
|
||||||
import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
|
import {
|
||||||
|
matchRoute,
|
||||||
|
childrenByPath,
|
||||||
|
computeSurfacePlacements,
|
||||||
|
computeRenderState,
|
||||||
|
placementKey,
|
||||||
|
useTabletopStore,
|
||||||
|
} from "./state.js";
|
||||||
|
|
||||||
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
||||||
return {
|
return {
|
||||||
type: 'board',
|
type: "board",
|
||||||
id: 'harbor',
|
id: "harbor",
|
||||||
layout: [],
|
layout: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkg: Package = {
|
const pkg: Package = {
|
||||||
meta: { id: 'harbor' },
|
meta: { id: "harbor" },
|
||||||
parts: new Map(),
|
parts: new Map(),
|
||||||
surfaces: new Map([
|
surfaces: new Map([
|
||||||
['board#harbor', makeSurface()],
|
["board#harbor", makeSurface()],
|
||||||
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
|
["hud#hand", makeSurface({ type: "hud", id: "hand" })],
|
||||||
]),
|
]),
|
||||||
setups: new Map(),
|
setups: new Map(),
|
||||||
|
dialogs: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('matchRoute', () => {
|
describe("matchRoute", () => {
|
||||||
it('matches a literal path', () => {
|
it("matches a literal path", () => {
|
||||||
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
|
const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
|
||||||
expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined });
|
expect(matchRoute(route, "/deck")).toEqual({ candidate: undefined });
|
||||||
expect(matchRoute(route, '/other')).toBeNull();
|
expect(matchRoute(route, "/other")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches a :param against a candidate', () => {
|
it("matches a :param against a candidate", () => {
|
||||||
const route = {
|
const route = {
|
||||||
route: '/dock/:seat',
|
route: "/dock/:seat",
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
candidates: [
|
candidates: [
|
||||||
{ seat: '0', x: 40, y: 0 },
|
{ seat: "0", x: 40, y: 0 },
|
||||||
{ seat: '1', x: 40, y: 20 },
|
{ 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 = {
|
const route = {
|
||||||
route: '/dock/:seat',
|
route: "/dock/:seat",
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
rotation: 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', () => {
|
it("fails on length mismatch", () => {
|
||||||
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
|
const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
|
||||||
expect(matchRoute(route, '/deck/extra')).toBeNull();
|
expect(matchRoute(route, "/deck/extra")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('computeSurfacePlacements', () => {
|
describe("childrenByPath", () => {
|
||||||
it('places parts on a matching route with index and stackSize', () => {
|
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({
|
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, {
|
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).toHaveLength(2);
|
||||||
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 });
|
expect(placements[0]).toMatchObject({
|
||||||
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 });
|
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', () => {
|
it("drops parts with no matching route", () => {
|
||||||
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
const surface = makeSurface({
|
||||||
|
layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }],
|
||||||
|
});
|
||||||
const placements = computeSurfacePlacements(surface, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['harbor:card#a'],
|
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
|
||||||
'/elsewhere': ['harbor:card#b'],
|
"harbor:card#b": {
|
||||||
|
path: "/elsewhere",
|
||||||
|
index: 0,
|
||||||
|
facing: "face" as Facing,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(1);
|
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({
|
const surface = makeSurface({
|
||||||
layout: [
|
layout: [
|
||||||
{
|
{
|
||||||
route: '/dock/:seat',
|
route: "/dock/:seat",
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
rotation: 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, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['poker:card#as', 'poker:card#kh'],
|
"harbor:boat#fleet": {
|
||||||
'/community/0': ['poker:card#as'],
|
path: "/dock/0",
|
||||||
|
index: 0,
|
||||||
|
facing: "face" as Facing,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(3);
|
expect(placements[0]!.candidate).toEqual({
|
||||||
const deck = placements.filter((p) => p.path === '/deck');
|
seat: "0",
|
||||||
const flop = placements.filter((p) => p.path === '/community/0');
|
x: 40,
|
||||||
expect(deck).toHaveLength(2);
|
y: 5,
|
||||||
expect(flop).toHaveLength(1);
|
rotation: 1,
|
||||||
// The same piece on two paths yields distinct placement keys.
|
});
|
||||||
expect(placementKey(deck[0]!)).not.toBe(placementKey(flop[0]!));
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe("computeRenderState", () => {
|
||||||
it('only includes enabled surfaces', () => {
|
it("only includes enabled surfaces", () => {
|
||||||
const state = {
|
const state = {
|
||||||
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
surfaces: { "board#harbor": true, "hud#hand": false },
|
||||||
paths: { '/deck': ['harbor:card#a'] },
|
parts: {
|
||||||
|
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
pkg.surfaces.set(
|
pkg.surfaces.set(
|
||||||
'board#harbor',
|
"board#harbor",
|
||||||
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }),
|
makeSurface({ layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }] }),
|
||||||
);
|
);
|
||||||
const placements = computeRenderState(pkg, state);
|
const placements = computeRenderState(pkg, state);
|
||||||
expect(placements).toHaveLength(1);
|
expect(placements).toHaveLength(1);
|
||||||
expect(placements[0]!.surface).toBe('board#harbor');
|
expect(placements[0]!.surface).toBe("board#harbor");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('placementKey', () => {
|
describe("useTabletopStore", () => {
|
||||||
it('is unique per surface, path, and piece', () => {
|
beforeEach(() => {
|
||||||
const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never;
|
useTabletopStore.setState({ surfaces: {}, parts: {} });
|
||||||
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.
|
it("seeds surfaces and parts", () => {
|
||||||
const d = { surface: 'board#harbor', path: '/community/0', piece: 'harbor:card#a' } as never;
|
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(b));
|
||||||
expect(placementKey(a)).not.toBe(placementKey(c));
|
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.
|
* Source-of-truth game state and the derived render state.
|
||||||
*
|
*
|
||||||
* The store holds the enabled surfaces and the path -> part placement map
|
* The store holds the enabled surfaces and a per-part placement map
|
||||||
* (`bgm-tabletop.md` §2). The derived render state is computed from the game
|
* (`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
|
* state plus a package's surface routes: a stable list of placements, one per
|
||||||
* (surface, piece) pair, keyed for rendering.
|
* (surface, piece) pair, keyed for rendering.
|
||||||
*/
|
*/
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { create } from 'zustand';
|
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. */
|
/** Source-of-truth game state. */
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
/** Enabled per surface id (`type#id`). */
|
/** Enabled per surface id (`type#id`). */
|
||||||
surfaces: Record<string, boolean>;
|
surfaces: Record<string, boolean>;
|
||||||
/** Path -> part list (`package:type#id`). */
|
/** Part id (`package:type#id`) -> placement state. */
|
||||||
paths: Record<string, string[]>;
|
parts: Record<string, PartState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single placed piece on a surface, ready for rendering. */
|
/** A single placed piece on a surface, ready for rendering. */
|
||||||
@@ -34,26 +47,59 @@ export interface Placement {
|
|||||||
index: number;
|
index: number;
|
||||||
/** The number of pieces on the path. */
|
/** The number of pieces on the path. */
|
||||||
stackSize: number;
|
stackSize: number;
|
||||||
|
/** How the piece is oriented on the board. */
|
||||||
|
facing: Facing;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabletopState extends GameState {
|
interface TabletopState extends GameState {
|
||||||
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
||||||
setPaths: (paths: Record<string, string[]>) => void;
|
setParts: (parts: Record<string, PartState>) => void;
|
||||||
seed: (state: GameState) => void;
|
seed: (state: GameState) => void;
|
||||||
enableSurface: (id: string) => void;
|
enableSurface: (id: string) => void;
|
||||||
disableSurface: (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) => ({
|
export const useTabletopStore = create<TabletopState>((set) => ({
|
||||||
surfaces: {},
|
surfaces: {},
|
||||||
paths: {},
|
parts: {},
|
||||||
setSurfaces: (surfaces) => set({ surfaces }),
|
setSurfaces: (surfaces) => set({ surfaces }),
|
||||||
setPaths: (paths) => set({ paths }),
|
setParts: (parts) => set({ parts }),
|
||||||
seed: (state) => set(state),
|
seed: (state) => set(state),
|
||||||
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
||||||
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
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 ---
|
// --- Route matching ---
|
||||||
@@ -87,24 +133,43 @@ export function matchRoute(route: Route, path: string): { candidate?: Candidate
|
|||||||
return { candidate: undefined };
|
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 placements: Placement[] = [];
|
||||||
const surfaceId = `${surface.type}#${surface.id}`;
|
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));
|
const route = surface.layout.find((r) => matchRoute(r, path));
|
||||||
if (!route) continue;
|
if (!route) continue;
|
||||||
const match = matchRoute(route, path)!;
|
const match = matchRoute(route, path)!;
|
||||||
const stackSize = parts.length;
|
const stackSize = ids.length;
|
||||||
for (const piece of parts) {
|
for (const piece of ids) {
|
||||||
|
const ps = parts[piece]!;
|
||||||
placements.push({
|
placements.push({
|
||||||
surface: surfaceId,
|
surface: surfaceId,
|
||||||
path,
|
path,
|
||||||
route,
|
route,
|
||||||
candidate: match.candidate,
|
candidate: match.candidate,
|
||||||
piece,
|
piece,
|
||||||
index: parts.indexOf(piece),
|
index: ps.index,
|
||||||
stackSize,
|
stackSize,
|
||||||
|
facing: ps.facing,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,19 +183,23 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[]
|
|||||||
if (!enabled) continue;
|
if (!enabled) continue;
|
||||||
const surface = pkg.surfaces.get(surfaceId);
|
const surface = pkg.surfaces.get(surfaceId);
|
||||||
if (!surface) continue;
|
if (!surface) continue;
|
||||||
out.push(...computeSurfacePlacements(surface, state.paths));
|
out.push(...computeSurfacePlacements(surface, state.parts));
|
||||||
}
|
}
|
||||||
return out;
|
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 {
|
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. */
|
/** The derived render state for a package, from the current game state. */
|
||||||
export function useRenderState(pkg: Package): Placement[] {
|
export function useRenderState(pkg: Package): Placement[] {
|
||||||
const surfaces = useTabletopStore((s) => s.surfaces);
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
const paths = useTabletopStore((s) => s.paths);
|
const parts = useTabletopStore((s) => s.parts);
|
||||||
return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]);
|
return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]);
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,9 @@ export function SurfaceBounds({ surface }: { surface: Surface }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<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' }}>
|
<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">
|
<div className="rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
||||||
{surface.type}#{surface.id}
|
{surface.type}#{surface.id}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { deserialize } from 'bson';
|
import { deserialize } from 'bson';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails, TTSMod } from '@tts/shared';
|
||||||
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
|
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
|
||||||
|
|
||||||
const STEAM_API_URL =
|
const STEAM_API_URL =
|
||||||
@@ -36,6 +36,30 @@ export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
|||||||
return fetchModFromUrl(fileUrl);
|
return fetchModFromUrl(fileUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a save plus its Workshop metadata (title, preview image) in one call.
|
||||||
|
* The metadata comes from the same Steam API request that resolves the save
|
||||||
|
* URL, so it costs no extra round-trip.
|
||||||
|
*
|
||||||
|
* @param id Workshop item ID (digits only).
|
||||||
|
* @param apiKey Steam Web API key.
|
||||||
|
*/
|
||||||
|
export async function fetchModDetails(
|
||||||
|
id: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<ModDetails> {
|
||||||
|
const details = await getPublishedFileDetails(id, apiKey);
|
||||||
|
if (!details.file_url) {
|
||||||
|
throw new NoFileError(id);
|
||||||
|
}
|
||||||
|
const mod = await fetchModFromUrl(details.file_url);
|
||||||
|
return {
|
||||||
|
mod,
|
||||||
|
title: details.title,
|
||||||
|
previewImageUrl: details.preview_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a TTS save from a direct URL and BSON-deserialize it.
|
* Download a TTS save from a direct URL and BSON-deserialize it.
|
||||||
*
|
*
|
||||||
@@ -94,6 +118,18 @@ async function downloadSave(fileUrl: string): Promise<ArrayBuffer> {
|
|||||||
|
|
||||||
/** Resolve the `file_url` for a Workshop item via the Steam API. */
|
/** Resolve the `file_url` for a Workshop item via the Steam API. */
|
||||||
async function getFileUrl(id: string, apiKey: string): Promise<string> {
|
async function getFileUrl(id: string, apiKey: string): Promise<string> {
|
||||||
|
const details = await getPublishedFileDetails(id, apiKey);
|
||||||
|
if (!details.file_url) {
|
||||||
|
throw new NoFileError(id);
|
||||||
|
}
|
||||||
|
return details.file_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the Steam published-file details for a Workshop item. */
|
||||||
|
async function getPublishedFileDetails(
|
||||||
|
id: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<SteamPublishedFileDetails> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('key', apiKey);
|
params.append('key', apiKey);
|
||||||
params.append('itemcount', '1');
|
params.append('itemcount', '1');
|
||||||
@@ -112,10 +148,7 @@ async function getFileUrl(id: string, apiKey: string): Promise<string> {
|
|||||||
if (!details) {
|
if (!details) {
|
||||||
throw new ItemNotFoundError(id);
|
throw new ItemNotFoundError(id);
|
||||||
}
|
}
|
||||||
if (!details.file_url) {
|
return details;
|
||||||
throw new NoFileError(id);
|
|
||||||
}
|
|
||||||
return details.file_url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from './errors.js';
|
export * from './errors.js';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user