fix(tabletop): key placements by surface, path, and piece

A piece can appear on more than one path of the same surface (e.g. the
poker deck expands to every card while the flop also places the ace), so
the render key must include the path to stay unique.
This commit is contained in:
2026-08-09 22:46:06 +08:00
parent f494a6f9be
commit d9d38c2bee
4 changed files with 37 additions and 10 deletions
+28 -4
View File
@@ -97,6 +97,27 @@ describe('computeSurfacePlacements', () => {
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] }); const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 }); expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
}); });
it('keeps the same piece on two paths as distinct placements', () => {
const surface = makeSurface({
layout: [
{ route: '/deck', x: 0, y: 0, rotation: 0 },
{ route: '/community/:slot', x: 0, y: 0, rotation: 0 },
],
});
// The deck expands to every card (including `as`); the flop also places `as`.
const placements = computeSurfacePlacements(surface, {
'/deck': ['poker:card#as', 'poker:card#kh'],
'/community/0': ['poker:card#as'],
});
expect(placements).toHaveLength(3);
const deck = placements.filter((p) => p.path === '/deck');
const flop = placements.filter((p) => p.path === '/community/0');
expect(deck).toHaveLength(2);
expect(flop).toHaveLength(1);
// The same piece on two paths yields distinct placement keys.
expect(placementKey(deck[0]!)).not.toBe(placementKey(flop[0]!));
});
}); });
describe('computeRenderState', () => { describe('computeRenderState', () => {
@@ -116,11 +137,14 @@ describe('computeRenderState', () => {
}); });
describe('placementKey', () => { describe('placementKey', () => {
it('is unique per surface and piece', () => { it('is unique per surface, path, and piece', () => {
const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never; const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never;
const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never; const b = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#b' } as never;
const c = { surface: 'hud#hand', piece: 'harbor:card#a' } as never; const c = { surface: 'hud#hand', path: '/deck', piece: 'harbor:card#a' } as never;
// The same piece on two paths of the same surface is a distinct placement.
const d = { surface: 'board#harbor', path: '/community/0', piece: 'harbor:card#a' } as never;
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));
}); });
}); });
+5 -2
View File
@@ -22,6 +22,8 @@ export interface GameState {
export interface Placement { export interface Placement {
/** Surface id (`type#id`). */ /** Surface id (`type#id`). */
surface: string; surface: string;
/** The path key this placement came from. */
path: string;
/** The matched route. */ /** The matched route. */
route: Route; route: Route;
/** The matched candidate, when the route has `:param`s. */ /** The matched candidate, when the route has `:param`s. */
@@ -97,6 +99,7 @@ export function computeSurfacePlacements(surface: Surface, paths: Record<string,
for (const piece of parts) { for (const piece of parts) {
placements.push({ placements.push({
surface: surfaceId, surface: surfaceId,
path,
route, route,
candidate: match.candidate, candidate: match.candidate,
piece, piece,
@@ -120,9 +123,9 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[]
return out; return out;
} }
/** A stable key for a placement, unique across surfaces and pieces. */ /** A stable key for a placement, unique across surfaces, paths, and pieces. */
export function placementKey(p: Placement): string { export function placementKey(p: Placement): string {
return `${p.surface}:${p.piece}`; return `${p.surface}:${p.path}:${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. */
@@ -7,7 +7,7 @@
*/ */
import { Html } from '@react-three/drei'; import { Html } from '@react-three/drei';
import type { Package } from '@tts/bgm'; import type { Package } from '@tts/bgm';
import { useRenderState } from '../state.js'; import { useRenderState, placementKey } from '../state.js';
import { PartPlacement } from '../placement.js'; import { PartPlacement } from '../placement.js';
import type { MountNode } from '../mount.js'; import type { MountNode } from '../mount.js';
import { SurfaceNode } from './WorldSurfaceView.js'; import { SurfaceNode } from './WorldSurfaceView.js';
@@ -24,7 +24,7 @@ export function HudSurfaceView({ pkg, node }: { pkg: Package; node: MountNode })
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
> >
{own.map((p) => ( {own.map((p) => (
<PartPlacement key={`${p.surface}:${p.piece}`} pkg={pkg} placement={p} /> <PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
))} ))}
{node.children.map((child) => ( {node.children.map((child) => (
<SurfaceNode key={child.id} pkg={pkg} node={child} /> <SurfaceNode key={child.id} pkg={pkg} node={child} />
@@ -7,7 +7,7 @@
* it's never rendered. * it's never rendered.
*/ */
import type { Package } from '@tts/bgm'; import type { Package } from '@tts/bgm';
import { useRenderState } from '../state.js'; import { useRenderState, placementKey } from '../state.js';
import { PartPlacement } from '../placement.js'; import { PartPlacement } from '../placement.js';
import type { MountNode } from '../mount.js'; import type { MountNode } from '../mount.js';
@@ -23,7 +23,7 @@ export function SurfaceNode({ pkg, node }: { pkg: Package; node: MountNode }) {
return ( return (
<group position={[node.x, 0, node.y]} rotation={[0, node.rotation, 0]}> <group position={[node.x, 0, node.y]} rotation={[0, node.rotation, 0]}>
{own.map((p) => ( {own.map((p) => (
<PartPlacement key={`${p.surface}:${p.piece}`} pkg={pkg} placement={p} /> <PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
))} ))}
{node.children.map((child) => ( {node.children.map((child) => (
<SurfaceNode key={child.id} pkg={pkg} node={child} /> <SurfaceNode key={child.id} pkg={pkg} node={child} />