diff --git a/packages/tabletop/src/interactions.test.ts b/packages/tabletop/src/interactions.test.ts index 0c8135a..99d70fa 100644 --- a/packages/tabletop/src/interactions.test.ts +++ b/packages/tabletop/src/interactions.test.ts @@ -1,28 +1,29 @@ -import { describe, expect, it } from 'vitest'; -import type { Package, Setup } from '@tts/bgm'; +import { beforeEach, describe, expect, it } from "vitest"; +import type { Package, Setup } from "@tts/bgm"; import { interactionsFor, dropPaths, pickPath, partFacings, nextFacing, -} from './interactions.js'; + useInteractionStore, +} from "./interactions.js"; const pkg: Package = { - meta: { id: 'harbor' }, + meta: { id: "harbor" }, parts: new Map([ - ['card#a', { type: 'card', id: 'a' }], - ['card#b', { type: 'card', id: 'b', facing: ['face', 'back'] }], + ["card#a", { type: "card", id: "a" }], + ["card#b", { type: "card", id: "b", facing: ["face", "back"] }], ]), surfaces: new Map([ [ - 'board#harbor', + "board#harbor", { - type: 'board', - id: 'harbor', + type: "board", + id: "harbor", layout: [ - { route: '/deck', x: 0, y: 0, rotation: 0 }, - { route: '/discard', x: 40, y: 0, rotation: 0 }, + { route: "/deck", x: 0, y: 0, rotation: 0 }, + { route: "/discard", x: 40, y: 0, rotation: 0 }, ], }, ], @@ -32,77 +33,156 @@ const pkg: Package = { }; const setup: Setup = { - type: 'game', - id: 'main', + type: "game", + id: "main", setup: [], interactions: [ - { dialog: 'prompt#insert', on: ['/deck'] }, - { dialog: 'prompt#shuffle' }, + { dialog: "prompt#insert", on: ["/deck"] }, + { dialog: "prompt#shuffle" }, ], }; -describe('interactionsFor', () => { - it('returns the interactions whose `on` matches the path, plus any without `on`', () => { - expect(interactionsFor(setup, '/deck').map((i) => i.dialog)).toEqual([ - 'prompt#insert', - 'prompt#shuffle', +describe("interactionsFor", () => { + it("returns the interactions whose `on` matches the path, plus any without `on`", () => { + expect(interactionsFor(setup, "/deck").map((i) => i.dialog)).toEqual([ + "prompt#insert", + "prompt#shuffle", ]); }); - it('includes interactions without `on` for any path', () => { - expect(interactionsFor(setup, '/discard').map((i) => i.dialog)).toEqual([ - 'prompt#shuffle', + it("includes interactions without `on` for any path", () => { + expect(interactionsFor(setup, "/discard").map((i) => i.dialog)).toEqual([ + "prompt#shuffle", ]); }); - it('returns [] when the setup declares no interactions', () => { + it("returns [] when the setup declares no interactions", () => { expect( - interactionsFor({ type: 'game', id: 'main', setup: [] }, '/deck'), + interactionsFor({ type: "game", id: "main", setup: [] }, "/deck"), ).toEqual([]); }); }); -describe('dropPaths', () => { - it('uses the declared `on` paths when interactions exist', () => { - expect([...dropPaths(setup, pkg)]).toEqual(['/deck']); +describe("dropPaths", () => { + it("uses the declared `on` paths when interactions exist", () => { + expect([...dropPaths(setup, pkg)]).toEqual(["/deck"]); }); - it('falls back to every literal routed path when no interactions are declared', () => { - const bare: Setup = { type: 'game', id: 'main', setup: [] }; - expect([...dropPaths(bare, pkg)].sort()).toEqual(['/deck', '/discard']); + it("falls back to every literal routed path when no interactions are declared", () => { + const bare: Setup = { type: "game", id: "main", setup: [] }; + expect([...dropPaths(bare, pkg)].sort()).toEqual(["/deck", "/discard"]); + }); + + it("skips :param routes in the fallback, since they need a candidate", () => { + const paramPkg: Package = { + ...pkg, + surfaces: new Map([ + [ + "board#harbor", + { + type: "board", + id: "harbor", + layout: [ + { route: "/dock/:seat", x: 0, y: 0, rotation: 0 }, + { route: "/deck", x: 40, y: 0, rotation: 0 }, + ], + }, + ], + ]), + }; + const bare: Setup = { type: "game", id: "main", setup: [] }; + expect([...dropPaths(bare, paramPkg)]).toEqual(["/deck"]); }); }); -describe('pickPath', () => { - it('returns the nearest literal route within the threshold', () => { - expect(pickPath(pkg, 'board#harbor', 41, 1, 40)).toBe('/discard'); +describe("pickPath", () => { + it("returns the nearest literal route within the threshold", () => { + expect(pickPath(pkg, "board#harbor", 41, 1, 40)).toBe("/discard"); }); - it('returns null when nothing is within the threshold', () => { - expect(pickPath(pkg, 'board#harbor', 100, 100, 40)).toBeNull(); + it("returns null when nothing is within the threshold", () => { + expect(pickPath(pkg, "board#harbor", 100, 100, 40)).toBeNull(); }); - it('returns null for an unknown surface', () => { - expect(pickPath(pkg, 'board#nope', 0, 0, 40)).toBeNull(); + it("returns null for an unknown surface", () => { + expect(pickPath(pkg, "board#nope", 0, 0, 40)).toBeNull(); + }); + + it("skips :param routes, which have no fixed anchor", () => { + const paramPkg: Package = { + ...pkg, + surfaces: new Map([ + [ + "board#harbor", + { + type: "board", + id: "harbor", + layout: [ + { route: "/dock/:seat", x: 0, y: 0, rotation: 0 }, + { route: "/deck", x: 40, y: 0, rotation: 0 }, + ], + }, + ], + ]), + }; + expect(pickPath(paramPkg, "board#harbor", 100, 100, 40)).toBeNull(); + expect(pickPath(paramPkg, "board#harbor", 41, 1, 40)).toBe("/deck"); }); }); -describe('partFacings / nextFacing', () => { - it('defaults to the full set when the part declares none', () => { - expect(partFacings(pkg.parts.get('card#a')!)).toEqual([ - 'face', - 'back', - 'standing', +describe("useInteractionStore", () => { + beforeEach(() => { + useInteractionStore.setState({ held: null, dialogs: [] }); + }); + + it("holds and drops a part", () => { + useInteractionStore + .getState() + .hold({ id: "harbor:card#a", origin: "/deck" }); + expect(useInteractionStore.getState().held).toEqual({ + id: "harbor:card#a", + origin: "/deck", + }); + useInteractionStore.getState().drop(); + expect(useInteractionStore.getState().held).toBeNull(); + }); + + it("pushes and pops the dialog stack", () => { + const { pushDialog, popDialog } = useInteractionStore.getState(); + pushDialog({ id: "prompt#insert", path: "/deck" }); + pushDialog({ id: "prompt#shuffle", path: "/deck" }); + expect(useInteractionStore.getState().dialogs).toEqual([ + { id: "prompt#insert", path: "/deck" }, + { id: "prompt#shuffle", path: "/deck" }, + ]); + popDialog(); + expect(useInteractionStore.getState().dialogs).toEqual([ + { id: "prompt#insert", path: "/deck" }, + ]); + }); +}); + +describe("partFacings / nextFacing", () => { + it("defaults to the full set when the part declares none", () => { + expect(partFacings(pkg.parts.get("card#a")!)).toEqual([ + "face", + "back", + "standing", ]); }); - it('uses the declared affordance', () => { - expect(partFacings(pkg.parts.get('card#b')!)).toEqual(['face', 'back']); + it("uses the declared affordance", () => { + expect(partFacings(pkg.parts.get("card#b")!)).toEqual(["face", "back"]); }); - it('cycles forward through the affordance', () => { - const part = pkg.parts.get('card#b')!; - expect(nextFacing(part, 'face')).toBe('back'); - expect(nextFacing(part, 'back')).toBe('face'); + it("cycles forward through the affordance", () => { + const part = pkg.parts.get("card#b")!; + expect(nextFacing(part, "face")).toBe("back"); + expect(nextFacing(part, "back")).toBe("face"); + }); + + it("wraps to the first facing after the last", () => { + const part = pkg.parts.get("card#b")!; + expect(nextFacing(part, "back")).toBe("face"); }); }); diff --git a/packages/tabletop/src/stacking.test.ts b/packages/tabletop/src/stacking.test.ts index f9e965e..fdc87d3 100644 --- a/packages/tabletop/src/stacking.test.ts +++ b/packages/tabletop/src/stacking.test.ts @@ -1,131 +1,238 @@ -import { describe, expect, it } from 'vitest'; -import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js'; +import { describe, expect, it } from "vitest"; +import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js"; -describe('parsePath', () => { - it('measures a straight line', () => { - const path = parsePath('M 0 0 L 10 0'); +describe("parsePath", () => { + it("measures a straight line", () => { + const path = parsePath("M 0 0 L 10 0"); expect(path.length).toBeCloseTo(10); }); - it('measures a cubic curve', () => { - const path = parsePath('M 0 0 C 20 -20 40 -20 60 0'); + it("measures a cubic curve", () => { + const path = parsePath("M 0 0 C 20 -20 40 -20 60 0"); // Longer than the chord (60) but finite. expect(path.length).toBeGreaterThan(60); expect(path.length).toBeLessThan(80); }); - it('handles relative commands', () => { - const path = parsePath('m 0 0 l 10 0 l 0 10'); + it("handles relative commands", () => { + const path = parsePath("m 0 0 l 10 0 l 0 10"); expect(path.length).toBeCloseTo(20); }); - it('supports h/v/z', () => { - const path = parsePath('M 0 0 H 10 V 10 Z'); + it("supports h/v/z", () => { + const path = parsePath("M 0 0 H 10 V 10 Z"); // 10 right + 10 down + the diagonal back to the start (closes the triangle). expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200)); }); + + it("supports quadratic curves (Q)", () => { + const path = parsePath("M 0 0 Q 50 50 100 0"); + // Longer than the chord (100) but finite. + expect(path.length).toBeGreaterThan(100); + expect(path.length).toBeLessThan(120); + // The curve passes through the midpoint of the control point. + const mid = pointAt(path, path.length / 2); + expect(mid.y).toBeGreaterThan(0); + }); + + it("supports smooth quadratic continuation (T)", () => { + const path = parsePath("M 0 0 Q 50 50 100 0 T 200 0"); + // Two quadratic segments; the second reflects the first's control point. + expect(path.length).toBeGreaterThan(200); + }); + + it("supports smooth cubic continuation (S)", () => { + const path = parsePath("M 0 0 C 25 50 75 50 100 0 S 175 -50 200 0"); + expect(path.length).toBeGreaterThan(200); + }); + + it("supports elliptical arcs (A)", () => { + // A semicircle of radius 50: length ≈ π * 50. + const path = parsePath("M 0 0 A 50 50 0 0 1 100 0"); + expect(path.length).toBeCloseTo(Math.PI * 50, 0); + }); + + it("supports relative variants of each command", () => { + const path = parsePath("m 0 0 q 50 50 100 0 t 100 0"); + expect(path.length).toBeGreaterThan(200); + }); + + it("throws on an unsupported command", () => { + expect(() => parsePath("M 0 0 R 10 10")).toThrow( + /Unsupported SVG path command/, + ); + }); }); -describe('pointAt', () => { - it('returns the start at distance 0 and end at full length', () => { - const path = parsePath('M 0 0 L 10 0'); +describe("pointAt", () => { + it("returns the start at distance 0 and end at full length", () => { + const path = parsePath("M 0 0 L 10 0"); expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 }); const end = pointAt(path, path.length); expect(end.x).toBeCloseTo(10); expect(end.y).toBeCloseTo(0); }); - it('interpolates along the path', () => { - const path = parsePath('M 0 0 L 10 0'); + it("interpolates along the path", () => { + const path = parsePath("M 0 0 L 10 0"); const mid = pointAt(path, 5); expect(mid.x).toBeCloseTo(5); expect(mid.angle).toBeCloseTo(0); }); - it('returns the tangent angle in degrees', () => { - const path = parsePath('M 0 0 L 10 10'); + it("returns the tangent angle in degrees", () => { + const path = parsePath("M 0 0 L 10 10"); expect(pointAt(path, 5).angle).toBeCloseTo(45); - const down = parsePath('M 0 0 L 0 10'); + const down = parsePath("M 0 0 L 0 10"); expect(pointAt(down, 5).angle).toBeCloseTo(90); }); - it('returns the tangent angle at the start of the path', () => { - const path = parsePath('M 0 0 L 10 10'); + it("returns the tangent angle at the start of the path", () => { + const path = parsePath("M 0 0 L 10 10"); expect(pointAt(path, 0).angle).toBeCloseTo(45); }); + + it("clamps distance to the path length", () => { + const path = parsePath("M 0 0 L 10 0"); + expect(pointAt(path, 999).x).toBeCloseTo(10); + expect(pointAt(path, -5).x).toBeCloseTo(0); + }); + + it("returns the origin for an empty path", () => { + expect(pointAt({ points: [], length: 0 }, 5)).toEqual({ + x: 0, + y: 0, + angle: 0, + }); + }); + + it("returns the single point for a degenerate path", () => { + const path = parsePath("M 5 5"); + expect(pointAt(path, 0)).toEqual({ x: 5, y: 5, angle: 0 }); + }); }); -describe('stackingOffset', () => { - it('defaults to a 1° tilt without a curve', () => { - expect(stackingOffset(undefined, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 }); - expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 }); +describe("stackingOffset", () => { + it("defaults to a 1° tilt without a curve", () => { + expect(stackingOffset(undefined, 0, 3)).toEqual({ + x: 0, + y: 0, + rotation: 0, + z: 0, + tilt: 1, + }); + expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ + x: 0, + y: 0, + rotation: 0, + z: 0, + tilt: 1, + }); }); - it('spreads parts evenly along a straight curve', () => { - const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3); + it("spreads parts evenly along a straight curve", () => { + const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3); // step = length / max(steps=1, 2) = 50; part 1 at 50. expect(offset.x).toBeCloseTo(50); expect(offset.y).toBeCloseTo(0); }); - it('aligns to center', () => { - const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3); + it("aligns to center", () => { + const offset = stackingOffset( + { curve: "M 0 0 L 100 0", align: "center" }, + 0, + 3, + ); // span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0. expect(offset.x).toBeCloseTo(0); }); - it('aligns to end', () => { - const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3); + it("aligns to end", () => { + const offset = stackingOffset( + { curve: "M 0 0 L 100 0", align: "end" }, + 2, + 3, + ); // start = 100 - 100 = 0; part 2 at 100. expect(offset.x).toBeCloseTo(100); }); - it('respects a positive limit (first n)', () => { - const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4); + it("respects a positive limit (first n)", () => { + const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: 2 }, 2, 4); // Part 2 is beyond the first 2 shown -> not placed. expect(offset).toBe(NO_OFFSET); }); - it('respects a negative limit (last n)', () => { + it("respects a negative limit (last n)", () => { // Last 2 of 4 are indices 2,3. Part 2 is the first shown. - const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4); + const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: -2 }, 2, 4); expect(offset.x).toBeCloseTo(0); }); - it('uses steps to densify the curve', () => { + it("uses steps to densify the curve", () => { // steps=4, 3 parts -> step = 100 / max(4, 2) = 25. - const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3); + const offset = stackingOffset({ curve: "M 0 0 L 100 0", steps: 4 }, 1, 3); expect(offset.x).toBeCloseTo(25); }); - it('tilts every part the same amount without a curve', () => { + it("tilts every part the same amount without a curve", () => { const offset = stackingOffset({ tilt: 0.1 }, 2, 3); expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 }); }); - it('tilts every part the same amount along the curve', () => { - const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3); + it("tilts every part the same amount along the curve", () => { + const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3); expect(offset.x).toBeCloseTo(50); expect(offset.tilt).toBeCloseTo(0.1); }); - it('tilts only the shown parts', () => { + it("tilts only the shown parts", () => { // limit 2 shows indices 0,1; index 2 is dropped. expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET); expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1); }); - it('ramps z from zStart to zEnd across the curve', () => { + it("ramps z from zStart to zEnd across the curve", () => { // 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40. - const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3); - const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3); - const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3); + const first = stackingOffset( + { curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 }, + 0, + 3, + ); + const mid = stackingOffset( + { curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 }, + 1, + 3, + ); + const last = stackingOffset( + { curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 }, + 2, + 3, + ); expect(first.z).toBeCloseTo(0); expect(mid.z).toBeCloseTo(20); expect(last.z).toBeCloseTo(40); }); - it('returns no offset for an empty stack', () => { + it("returns no offset for an empty stack", () => { expect(stackingOffset(undefined, 0, 0)).toBe(NO_OFFSET); }); -}); \ No newline at end of file + + it("applies only the default tilt when a curve has zero length", () => { + // A degenerate curve (a single point) has length 0, so no horizontal + // offset applies, but the default 1° tilt still does. + expect(stackingOffset({ curve: "M 5 5" }, 0, 3)).toEqual({ + x: 0, + y: 0, + rotation: 0, + z: 0, + tilt: 1, + }); + }); + + it("places a single part at the start of the curve", () => { + const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 0, 1); + // span = max(steps=1, 0) = 1; step = 100; part 0 at 0. + expect(offset.x).toBeCloseTo(0); + }); +}); diff --git a/packages/tabletop/src/state.test.ts b/packages/tabletop/src/state.test.ts index 39f93b9..010599b 100644 --- a/packages/tabletop/src/state.test.ts +++ b/packages/tabletop/src/state.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Facing, Package, Surface } from "@tts/bgm"; import { matchRoute, @@ -6,6 +6,7 @@ import { computeSurfacePlacements, computeRenderState, placementKey, + useTabletopStore, } from "./state.js"; function makeSurface(overrides: Partial = {}): Surface { @@ -212,6 +213,111 @@ describe("computeRenderState", () => { }); }); +describe("useTabletopStore", () => { + beforeEach(() => { + useTabletopStore.setState({ surfaces: {}, parts: {} }); + }); + + it("seeds surfaces and parts", () => { + useTabletopStore + .getState() + .seed({ surfaces: { "board#harbor": true }, parts: {} }); + expect(useTabletopStore.getState().surfaces).toEqual({ + "board#harbor": true, + }); + }); + + it("enables and disables a surface", () => { + useTabletopStore.getState().enableSurface("board#harbor"); + expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(true); + useTabletopStore.getState().disableSurface("board#harbor"); + expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(false); + }); + + it("setPart patches an existing part and ignores an unknown id", () => { + useTabletopStore.getState().setParts({ + "harbor:card#a": { path: "/deck", index: 0, facing: "face" }, + }); + useTabletopStore.getState().setPart("harbor:card#a", { facing: "back" }); + expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({ + path: "/deck", + index: 0, + facing: "back", + }); + useTabletopStore.getState().setPart("harbor:card#nope", { facing: "back" }); + expect( + useTabletopStore.getState().parts["harbor:card#nope"], + ).toBeUndefined(); + }); + + it("movePart reindexes source and destination, inserting at index", () => { + useTabletopStore.getState().setParts({ + "harbor:card#a": { path: "/deck", index: 0, facing: "face" }, + "harbor:card#b": { path: "/deck", index: 1, facing: "face" }, + "harbor:card#c": { path: "/deck", index: 2, facing: "face" }, + }); + // Move the top card to the bottom (index 0). + useTabletopStore.getState().movePart("harbor:card#c", "/deck", 0); + const parts = useTabletopStore.getState().parts; + expect(parts["harbor:card#c"]).toEqual({ + path: "/deck", + index: 0, + facing: "face", + }); + expect(parts["harbor:card#a"]).toEqual({ + path: "/deck", + index: 1, + facing: "face", + }); + expect(parts["harbor:card#b"]).toEqual({ + path: "/deck", + index: 2, + facing: "face", + }); + }); + + it("movePart moves between paths, closing the source gap", () => { + useTabletopStore.getState().setParts({ + "harbor:card#a": { path: "/deck", index: 0, facing: "face" }, + "harbor:card#b": { path: "/deck", index: 1, facing: "face" }, + "harbor:card#c": { path: "/discard", index: 0, facing: "face" }, + }); + useTabletopStore.getState().movePart("harbor:card#a", "/discard", 0); + const parts = useTabletopStore.getState().parts; + expect(parts["harbor:card#a"]).toEqual({ + path: "/discard", + index: 0, + facing: "face", + }); + expect(parts["harbor:card#c"]).toEqual({ + path: "/discard", + index: 1, + facing: "face", + }); + expect(parts["harbor:card#b"]).toEqual({ + path: "/deck", + index: 0, + facing: "face", + }); + }); + + it("movePart clamps index and ignores an unknown id", () => { + useTabletopStore.getState().setParts({ + "harbor:card#a": { path: "/deck", index: 0, facing: "face" }, + }); + useTabletopStore.getState().movePart("harbor:card#a", "/deck", 99); + expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({ + path: "/deck", + index: 0, + facing: "face", + }); + useTabletopStore.getState().movePart("harbor:card#nope", "/deck", 0); + expect( + useTabletopStore.getState().parts["harbor:card#nope"], + ).toBeUndefined(); + }); +}); + describe("placementKey", () => { it("is unique per surface and piece", () => { const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;