Replace the face/back boolean with a three-state facing (face, back, standing) seeded from setup placements. Parts now orient about the anchor at the resting face/edge, so back-down parts sit on the table instead of below it and standing parts rest on their bottom edge.
178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { Package, Surface } from '@tts/bgm';
|
|
import {
|
|
matchRoute,
|
|
childrenByPath,
|
|
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('childrenByPath', () => {
|
|
it('groups parts by path, ordered by index', () => {
|
|
const parts = {
|
|
'harbor:card#a': { path: '/deck', index: 1, facing: 'face' },
|
|
'harbor:card#b': { path: '/deck', index: 0, facing: 'back' },
|
|
'harbor:card#c': { path: '/community/0', index: 0, facing: 'standing' },
|
|
};
|
|
expect(childrenByPath(parts)).toEqual({
|
|
'/deck': ['harbor:card#b', 'harbor:card#a'],
|
|
'/community/0': ['harbor:card#c'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('computeSurfacePlacements', () => {
|
|
it('places parts on a matching route with index, stackSize, and facing', () => {
|
|
const surface = makeSurface({
|
|
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
|
|
});
|
|
const placements = computeSurfacePlacements(surface, {
|
|
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
|
|
'harbor:card#b': { path: '/deck', index: 1, facing: 'back' },
|
|
});
|
|
expect(placements).toHaveLength(2);
|
|
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2, facing: 'face' });
|
|
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, facing: 'back' });
|
|
});
|
|
|
|
it('drops parts with no matching route', () => {
|
|
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
|
const placements = computeSurfacePlacements(surface, {
|
|
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
|
|
'harbor:card#b': { path: '/elsewhere', index: 0, facing: 'face' },
|
|
});
|
|
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, {
|
|
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
|
|
});
|
|
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
|
|
});
|
|
|
|
it('keeps the candidate stacking alongside its anchor', () => {
|
|
const surface = makeSurface({
|
|
layout: [
|
|
{
|
|
route: '/dock/:seat',
|
|
x: 0,
|
|
y: 0,
|
|
rotation: 0,
|
|
candidates: [{ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }],
|
|
},
|
|
],
|
|
});
|
|
const placements = computeSurfacePlacements(surface, {
|
|
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
|
|
});
|
|
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' },
|
|
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
|
|
});
|
|
expect(placements.map((p) => p.piece)).toEqual(['harbor:card#a', 'harbor:card#b']);
|
|
});
|
|
});
|
|
|
|
describe('computeRenderState', () => {
|
|
it('only includes enabled surfaces', () => {
|
|
const state = {
|
|
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
|
parts: { 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' } },
|
|
};
|
|
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));
|
|
});
|
|
}); |