feat(tabletop): seed state from ordered setup placements

Apply each setup placement in order, moving its parts to its path, then index each path's parts contiguously so stacking stays valid.
This commit is contained in:
2026-08-10 11:27:50 +08:00
parent c2693277ac
commit 96c299e498
4 changed files with 38 additions and 9 deletions
@@ -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');
+18 -2
View File
@@ -40,7 +40,7 @@ describe('seedFromSetup', () => {
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 });
@@ -48,8 +48,24 @@ describe('seedFromSetup', () => {
}); });
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, face: true });
expect(state.parts['harbor:token#wood']).toEqual({ path: '/deck', index: 0, face: true });
});
}); });
+17 -5
View File
@@ -6,7 +6,7 @@
* `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, type PartState } from './state.js'; import { useTabletopStore, type PartState } from './state.js';
/** /**
@@ -15,7 +15,7 @@ import { useTabletopStore, type PartState } 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[] = [];
@@ -48,11 +48,23 @@ 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;
} }
// Apply placements in order: each moves its parts to its path. A part listed
// in a later placement ends up on that placement's path. Then index each
// path's parts contiguously, in placement order, so `index` is always a
// valid position within its path's stack.
const parts: Record<string, PartState> = {}; const parts: Record<string, PartState> = {};
for (const [path, value] of Object.entries(setup.setup)) { for (const placement of setup.setup) {
const ids = expandSetupValue(pkg, value); for (const id of expandSetupValue(pkg, placement.parts)) {
parts[id] = { path: placement.path, index: 0, face: true };
}
}
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) => { ids.forEach((id, index) => {
parts[id] = { path, index, face: true }; parts[id] = { ...parts[id]!, index };
}); });
} }
return { surfaces, parts }; return { surfaces, parts };