feat(bgm): add dialog role and interaction affordances

Add the dialog role and the interaction affordances the free-interaction
layer depends on: Setup.interactions, Package.dialogs, and the Interaction,
DialogAction, and Dialog types. Wire the dialog role through the zod
schemas, package collection (with duplicate checking), and vite
serialization, and update the tabletop serializedToPackage and test
fixtures to carry dialogs.
This commit is contained in:
hyper
2026-08-18 10:24:21 +08:00
parent 8eff44712f
commit 999b7a0771
10 changed files with 604 additions and 312 deletions
+17 -13
View File
@@ -1,19 +1,23 @@
import { describe, expect, it } from 'vitest';
import { serializedToPackage } from './package.js';
import { describe, expect, it } from "vitest";
import { serializedToPackage } from "./package.js";
describe('serializedToPackage', () => {
it('converts plain-object maps to Map instances', () => {
describe("serializedToPackage", () => {
it("converts plain-object maps to Map instances", () => {
const serialized = {
meta: { id: 'harbor' },
parts: { 'token#wood': { type: 'token', id: 'wood' } },
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } },
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } },
meta: { id: "harbor" },
parts: { "token#wood": { type: "token", id: "wood" } },
surfaces: { "board#harbor": { type: "board", id: "harbor", layout: [] } },
setups: { "game#main": { type: "game", id: "main", setup: [] } },
dialogs: {
"prompt#insert": { type: "prompt", id: "insert", title: "Insert" },
},
};
const pkg = serializedToPackage(serialized);
expect(pkg.meta.id).toBe('harbor');
expect(pkg.meta.id).toBe("harbor");
expect(pkg.parts).toBeInstanceOf(Map);
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' });
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' });
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' });
expect(pkg.parts.get("token#wood")).toEqual({ type: "token", id: "wood" });
expect(pkg.surfaces.get("board#harbor")).toMatchObject({ type: "board" });
expect(pkg.setups.get("game#main")).toMatchObject({ type: "game" });
expect(pkg.dialogs.get("prompt#insert")).toMatchObject({ type: "prompt" });
});
});
});
+3 -2
View File
@@ -4,7 +4,7 @@
* `virtual:bgm/*` get the serialized form; the tabletop components take the
* `Package` form.
*/
import type { Package, SerializedPackage } from '@tts/bgm';
import type { Package, SerializedPackage } from "@tts/bgm";
export function serializedToPackage(serialized: SerializedPackage): Package {
return {
@@ -12,5 +12,6 @@ export function serializedToPackage(serialized: SerializedPackage): Package {
parts: new Map(Object.entries(serialized.parts)),
surfaces: new Map(Object.entries(serialized.surfaces)),
setups: new Map(Object.entries(serialized.setups)),
dialogs: new Map(Object.entries(serialized.dialogs)),
};
}
}
+81 -51
View File
@@ -1,88 +1,118 @@
import { describe, expect, it } from 'vitest';
import type { Package } from '@tts/bgm';
import { expandSetupValue, seedFromSetup } from './setup.js';
import { describe, expect, it } from "vitest";
import type { Facing, Package } from "@tts/bgm";
import { expandSetupValue, seedFromSetup } from "./setup.js";
const pkg: Package = {
meta: { id: 'harbor' },
meta: { id: "harbor" },
parts: new Map([
['token#wood', { type: 'token', id: 'wood' }],
['token#grain', { type: 'token', id: 'grain' }],
['card#fleet', { type: 'card', id: 'fleet' }],
["token#wood", { type: "token", id: "wood" }],
["token#grain", { type: "token", id: "grain" }],
["card#fleet", { type: "card", id: "fleet" }],
]),
surfaces: new Map([
['board#harbor', { type: 'board', id: 'harbor', layout: [] }],
['hud#hand', { type: 'hud', id: 'hand', layout: [] }],
["board#harbor", { type: "board", id: "harbor", layout: [] }],
["hud#hand", { type: "hud", id: "hand", layout: [] }],
]),
setups: new Map(),
dialogs: new Map(),
};
describe('expandSetupValue', () => {
it('keeps a full part id', () => {
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',
describe("expandSetupValue", () => {
it("keeps a full part id", () => {
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"]);
});
});
describe('seedFromSetup', () => {
it('enables listed surfaces and places parts', () => {
describe("seedFromSetup", () => {
it("enables listed surfaces and places parts", () => {
const setup = {
type: 'game',
id: 'main',
surfaces: ['board#harbor'],
setup: [{ path: '/deck', parts: 'harbor:card#fleet' }],
type: "game",
id: "main",
surfaces: ["board#harbor"],
setup: [{ path: "/deck", parts: "harbor:card#fleet" }],
};
const state = seedFromSetup(pkg, setup);
expect(state.surfaces).toEqual({ 'board#harbor': true });
expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, facing: 'face' } });
expect(state.surfaces).toEqual({ "board#harbor": true });
expect(state.parts).toEqual({
"harbor:card#fleet": { path: "/deck", index: 0, facing: "face" },
});
});
it('enables all surfaces when omitted', () => {
const setup = { type: 'game', id: 'main', setup: [] };
it("enables all surfaces when omitted", () => {
const setup = { type: "game", id: "main", 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', () => {
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',
type: "game",
id: "main",
setup: [
{ path: '/deck', parts: ['harbor:card#fleet', 'harbor:token#wood'] },
{ path: '/hand', parts: 'harbor:card#fleet' },
{ 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' });
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', () => {
it("seeds the facing from the placement, defaulting to face", () => {
const setup = {
type: 'game',
id: 'main',
type: "game",
id: "main",
setup: [
{ path: '/deck', parts: 'harbor:card#fleet', facing: 'back' },
{ path: '/table', parts: 'harbor:token#wood', facing: 'standing' },
{ path: '/hand', parts: 'harbor:token#grain' },
{ 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' });
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' });
expect(state.parts["harbor:token#grain"]).toEqual({
path: "/hand",
index: 0,
facing: "face",
});
});
});
});
+118 -73
View File
@@ -1,178 +1,223 @@
import { describe, expect, it } from 'vitest';
import type { Package, Surface } from '@tts/bgm';
import { describe, expect, it } from "vitest";
import type { Facing, Package, Surface } from "@tts/bgm";
import {
matchRoute,
childrenByPath,
computeSurfacePlacements,
computeRenderState,
placementKey,
} from './state.js';
} from "./state.js";
function makeSurface(overrides: Partial<Surface> = {}): Surface {
return {
type: 'board',
id: 'harbor',
type: "board",
id: "harbor",
layout: [],
...overrides,
};
}
const pkg: Package = {
meta: { id: 'harbor' },
meta: { id: "harbor" },
parts: new Map(),
surfaces: new Map([
['board#harbor', makeSurface()],
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
["board#harbor", makeSurface()],
["hud#hand", makeSurface({ type: "hud", id: "hand" })],
]),
setups: new Map(),
dialogs: 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();
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', () => {
it("matches a :param against a candidate", () => {
const route = {
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 0,
rotation: 0,
candidates: [
{ seat: '0', x: 40, y: 0 },
{ seat: '1', x: 40, y: 20 },
{ 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 } });
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 = {
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 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', () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck/extra')).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' },
describe("childrenByPath", () => {
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'],
"/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', () => {
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 }],
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' },
"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[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' });
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 }] });
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' },
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
"harbor:card#b": {
path: "/elsewhere",
index: 0,
facing: "face" as Facing,
},
});
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({
layout: [
{
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 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, {
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
"harbor:boat#fleet": {
path: "/dock/0",
index: 0,
facing: "face" as Facing,
},
});
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 candidate stacking alongside its anchor', () => {
it("keeps the candidate stacking alongside its anchor", () => {
const surface = makeSurface({
layout: [
{
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 0,
rotation: 0,
candidates: [{ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }],
candidates: [{ seat: "0", x: 40, y: 5, stacking: { tilt: 2 } }],
},
],
});
const placements = computeSurfacePlacements(surface, {
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
"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 },
});
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } });
});
it('orders a path by index regardless of insertion order', () => {
it("orders a path by index regardless of insertion order", () => {
const surface = makeSurface({
layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }],
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' },
"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']);
expect(placements.map((p) => p.piece)).toEqual([
"harbor:card#a",
"harbor:card#b",
]);
});
});
describe('computeRenderState', () => {
it('only includes enabled surfaces', () => {
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' } },
surfaces: { "board#harbor": true, "hud#hand": false },
parts: {
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
},
};
pkg.surfaces.set(
'board#harbor',
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }),
"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');
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;
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));
});
});
});