feat(tabletop): add state, setup, mounting, and stacking

Implement the tabletop library's game state store, setup seeding with
bare-type expansion, surface mount tree resolution, part placement, and
the stacking positioning process with a dependency-free SVG path helper.
Wire the public API and add unit plus vite integration tests.
This commit is contained in:
2026-08-09 22:34:18 +08:00
parent ef0695ed04
commit cd08e6af04
20 changed files with 1281 additions and 11 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import type { Package, Surface } from '@tts/bgm';
import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
function makeSurface(overrides: Partial<Surface> = {}): Surface {
return {
type: 'board',
id: 'harbor',
layout: [],
...overrides,
};
}
const pkg: Package = {
meta: { id: 'harbor' },
parts: new Map(),
surfaces: new Map([
['board#harbor', makeSurface()],
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
]),
setups: new Map(),
};
describe('matchRoute', () => {
it('matches a literal path', () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined });
expect(matchRoute(route, '/other')).toBeNull();
});
it('matches a :param against a candidate', () => {
const route = {
route: '/dock/:seat',
x: 0,
y: 0,
rotation: 0,
candidates: [
{ seat: '0', x: 40, y: 0 },
{ seat: '1', x: 40, y: 20 },
],
};
expect(matchRoute(route, '/dock/1')).toEqual({ candidate: { seat: '1', x: 40, y: 20 } });
});
it('fails when no candidate matches the param', () => {
const route = {
route: '/dock/:seat',
x: 0,
y: 0,
rotation: 0,
candidates: [{ seat: '0', x: 40, y: 0 }],
};
expect(matchRoute(route, '/dock/9')).toBeNull();
});
it('fails on length mismatch', () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck/extra')).toBeNull();
});
});
describe('computeSurfacePlacements', () => {
it('places parts on a matching route with index and stackSize', () => {
const surface = makeSurface({
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
});
const placements = computeSurfacePlacements(surface, {
'/deck': ['harbor:card#a', 'harbor:card#b'],
});
expect(placements).toHaveLength(2);
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 });
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 });
});
it('drops parts with no matching route', () => {
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
const placements = computeSurfacePlacements(surface, {
'/deck': ['harbor:card#a'],
'/elsewhere': ['harbor:card#b'],
});
expect(placements).toHaveLength(1);
expect(placements[0]!.piece).toBe('harbor:card#a');
});
it('uses the candidate anchor for a :param route', () => {
const surface = makeSurface({
layout: [
{
route: '/dock/:seat',
x: 0,
y: 0,
rotation: 0,
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 });
});
});
describe('computeRenderState', () => {
it('only includes enabled surfaces', () => {
const state = {
surfaces: { 'board#harbor': true, 'hud#hand': false },
paths: { '/deck': ['harbor:card#a'] },
};
pkg.surfaces.set(
'board#harbor',
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }),
);
const placements = computeRenderState(pkg, state);
expect(placements).toHaveLength(1);
expect(placements[0]!.surface).toBe('board#harbor');
});
});
describe('placementKey', () => {
it('is unique per surface and piece', () => {
const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never;
const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never;
const c = { surface: 'hud#hand', piece: 'harbor:card#a' } as never;
expect(placementKey(a)).not.toBe(placementKey(b));
expect(placementKey(a)).not.toBe(placementKey(c));
});
});