Add bgm-commands.md covering async commands with ok/cancel/error results, per-invocation run contexts, fire-and-forget vs self-managed waiting, and tap interaction with part-local trigger points. Link it from the tabletop design and plan docs, and record the decisions in decisions.md.
14 KiB
Decisions
Scope: The rationale behind key design decisions. For the system's architecture, see
architecture.md. For the concrete build plan, seeimplementation-plan.md.Each entry records the decision, the context, and the alternatives considered. New entries are appended; existing entries are updated only to correct facts, not to rewrite history.
D1 — Client-only, no caching
Decision: The system runs on the client only and has no caching layer.
Context: The tool is a personal, single-user utility. There is no shared state to protect and no multi-instance deployment.
Alternatives considered: In-memory TTL caching; Redis-backed caching. Both rejected as unnecessary complexity for a single client.
D2 — Separate packages/extract from packages/tts
Decision: Analysis lives in packages/extract; fetching lives in
packages/tts. They are separate packages with a clean fetch-vs-analyze
boundary.
Context: The user wants to analyze saves and extract objects for a future frontend. Keeping analysis separate lets it be reused independently of the fetcher.
Alternatives considered: Folding analysis into packages/tts. Rejected —
couples fetch and analysis and would force the frontend to depend on the
fetcher.
D3 — Traversal lives in packages/extract
Decision: traverseMod / markParent move from the fetcher into
packages/extract.
Context: Traversal is analysis, not fetching. packages/tts should be
fetch-only.
Alternatives considered: Keeping traversal in packages/tts. Rejected —
blurs the fetch/analyze boundary.
D4 — packages/extract is isomorphic with zero runtime deps
Decision: packages/extract runs in browser and Node, using only fetch,
Blob, and typed arrays. It has no external runtime dependencies.
Context: The frontend (deferred) will consume it directly, so it must be
portable. Node 18+ provides global fetch and Blob.
Alternatives considered: Using Buffer and Node-only packages. Rejected —
breaks browser use.
D5 — No backend traversal endpoints
Decision: The proxy exposes search and fetch only. Traversal is not exposed as HTTP endpoints.
Context: The frontend will use packages/extract directly. Exposing
traversal on the backend would duplicate that logic and add endpoints with no
current consumer.
Alternatives considered: Adding /items/:id/objects and similar endpoints.
Deferred until a consumer exists.
D6 — Search scrapes the Workshop browse page
Decision: Search is implemented by scraping
steamcommunity.com/workshop/browse/?appid=286160 with cheerio.
Context: Steam has no official Web API for searching the Workshop; the API only fetches details for known IDs.
Alternatives considered: None viable — there is no official search API. Accepted risk: scraping is fragile and may break if Steam changes its HTML.
D7 — bson npm package replaces the browser BSON global
Decision: The existing scraper's BSON.deserialize global is replaced with
the bson npm package.
Context: The scraper was written for the browser; the proxy runs on Node.
Alternatives considered: Keeping a browser-only global. Rejected — not available in Node.
D8 — Download output type is Blob
Decision: packages/extract's downloadAsset / downloadAll return Blob.
Context: The primary consumer is a frontend that renders assets in
<img> / <object> elements.
Alternatives considered: Raw ArrayBuffer. Rejected — less convenient for
frontend rendering.
D9 — Frontend stack: React + React Router + Tailwind v4 + Zustand
Decision: The frontend (apps/web) uses React with React Router for
routing, Tailwind CSS v4 for styling, and Zustand for state, built with Vite.
Context: The frontend consumes the proxy API for search/fetch and
packages/extract directly for analysis. React is the de-facto standard for
this kind of tool; React Router provides declarative routes (/ and
/mod/:id); Tailwind v4 is the current major version and integrates via
@tailwindcss/vite; Zustand is a minimal, unopinionated store that fits the
small amount of client state (search + mod).
Alternatives considered: Next.js (heavier than needed for a client-only tool); Redux Toolkit (more boilerplate than warranted); CSS Modules (no utility styling).
D10 — Object inspection via a viewer registry
Decision: The mod page shows a containment-tree sidebar (mirroring how TTS
nests objects) and an inspector pane. The inspector resolves a per-class viewer
from a registry (registerViewer / resolveViewer), falling back to a
DefaultViewer that renders an object's raw fields.
Context: The user wants to visualize objects in a save. A flat list of all
objects and assets is hard to navigate, so the UI was reworked around a tree
and a selected-object inspector. Custom viewers per object class (Card,
Bag, ...) are expected later; a registry keeps that extension point explicit
without coupling the page to any one viewer.
Alternatives considered: A single monolithic inspector component. Rejected — would grow unboundedly as per-class viewers are added; the registry keeps each viewer isolated and swappable.
D11 — 3D viewers on the r3f/drei/postprocessing stack
Decision: Per-class 3D viewers for tiles, tokens, cards, and custom models
are built on @react-three/fiber, @react-three/drei, and
@react-three/postprocessing, registered through the existing viewer registry
and lazy-loaded so the three.js stack is code-split out of the main bundle.
Context: The user wants to visualize objects in a save in 3D. The viewer
registry (D10) already provides the extension point. Custom models load from
CustomMesh.MeshURL (GLTF/OBJ/FBX); tiles/tokens/cards use their
CustomImage/CustomDeck textures. Unity asset bundles are out of scope.
Alternatives considered: A single monolithic 3D inspector; a WebGL library other than three.js. Rejected — the registry keeps viewers isolated, and r3f is the de-facto React/three.js integration.
D12 — CORS-safe asset proxy
Decision: The proxy exposes GET /asset?url=..., which fetches an external
asset and streams it back with a Content-Type header. Only http(s) URLs are
allowed.
Context: Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
headers, which would block three.js TextureLoader / GLTFLoader in the
browser. Routing asset fetches through the proxy makes them loadable.
Alternatives considered: Loading assets directly in the browser. Rejected — CORS failures on common Workshop hosts would break the viewers.
D13 — Server-side image tracing to a mesh-ready shape
Decision: The proxy exposes GET /trace?url=..., which fetches an image,
traces it into a vector shape, and returns the result BSON-encoded. Tracing is
configurable: mode (alpha default, bw, color) selects how the region is
derived, format (shape default, svg) selects the response, and offset
(optional, in pixels) insets (negative) or outsets (positive) the resulting
shape — used by the token viewer to shave the anti-aliased fringe off a traced
silhouette. The shape format returns a parsed { outline, holes } polygon
matching @tts/mesh's Shape interface.
Context: The user wants to build a mesh from an image (e.g. a token or tile
art) using @tts/mesh. vtracer only returns SVG, but the mesh package consumes
{ outline, holes }. Tracing on the server keeps the SVG-to-shape parsing out
of the browser and lets the BSON response carry a ready-to-extrude shape.
sharp decodes the image to RGBA so alpha/luminance masks can be built before
feeding vtracer's convertPixels.
Alternatives considered: Returning only the raw SVG and parsing in the web
app near @tts/mesh; tracing by color only (no alpha). Rejected — server-side
parsing yields a shape the mesh package can consume directly, and alpha-based
tracing (the default) is the common case for token/tile art. For shape
inset/outset, clipper-lib (Angus Johnson's Clipper ported to JS) was chosen
over the polygon-offset package because the latter crashes on degenerate
cases (collapse, hole closure) via a bug in its pinned Martinez dependency.
D14 — Extrusion exposes separated front/back/walls
Decision: extrudeShapeParts returns { front, back, walls } as separate
geometries, and tessellate.ts exposes frontFaces / backFaces (with
capFaces kept as a merged convenience).
Context: Cards and tiles need distinct materials on the front and back faces, and the back must be flipped so it isn't mirrored when viewed from behind. Splitting the caps into front/back at the mesh level lets each viewer apply its own material without post-hoc geometry-group splitting.
Alternatives considered: Returning a single merged caps geometry and splitting it in the viewer (the previous approach for cards). Rejected — required manual triangle-group bookkeeping in the component.
D15 — Back faces are flipped on the material, not the geometry
Decision: The back face is un-mirrored by flipping the texture on the
material (flipTexture.ts negates repeat.x and shifts offset.x), rather
than by transforming the geometry's UVs.
Context: The back cap maps with the same planar UVs as the front, so without a flip it appears mirrored. Flipping on the material keeps the mesh geometry simple and shared, and works for both a full texture and a sprite cell.
Alternatives considered: Flipping the UVs in backFaces. Rejected —
would bake the flip into the shared mesh package, forcing it on every consumer
rather than letting viewers opt in.
D16 — Card sprite selection via CardID and the parent deck
Decision: A card's face/back sprite is selected from the deck sheet by
CardID (deck index in the hundreds place, 0-based card number in the last
two digits). The sheet config (grid, face/back URLs, UniqueBack) is resolved
from the containing deck's CustomDeck[deckIndex], which is authoritative
over the card's own CustomDeck (often keyed differently or absent).
Context: Deck images are sheets divided into a NumWidth x NumHeight
grid. The card's own CustomDeck field is unreliable — in the Wingspan dump a
card with CardID 1605 carries CustomDeck: {14: ...} even though its deck
index is 16 — so the parent deck is the source of truth.
Alternatives considered: Using the card's own CustomDeck. Rejected —
produces the wrong sprite for cards whose own field is mis-keyed or missing.
D17 — Tree selection by index path, not GUID
Decision: The object tree selects nodes by their unique index path (e.g.
0-3-1) rather than by GUID.
Context: Cards in a deck share the deck's GUID — in the Wingspan dump, 292 of 606 objects carry a duplicate GUID. GUID-based selection highlighted every card with that GUID and rendered the first match, so the wrong sprite could show.
Alternatives considered: Using GUID (the previous approach). Rejected —
ambiguous for decked cards.
D18 — Camera fit via drei Bounds inside Suspense
Decision: The viewer camera is fitted to the object's bounds using drei's
Bounds component, placed inside the scene's Suspense boundary so it mounts
only after the (suspending) content has loaded.
Context: Viewers load content asynchronously (textures suspend, tokens
trace via the proxy, models stream in). Bounds fits on mount, so it must
mount after the content is present. The token viewer was converted from
useEffect + state to a suspending resource so it participates in the same
boundary.
Alternatives considered: A polling CameraFit that waited for non-empty
bounds each frame; hand-rolled camera math. Rejected — Suspense already
signals content readiness, so Bounds inside the boundary fits the loaded
geometry directly.
D19 — Commands are async with ok/cancel/error results
Decision: Scripted interaction is built on async commands. Each command
returns ok, cancel (interrupted — superseded, skipped, surface disabled),
or error (genuinely failed). Each invocation gets its own run context — the
unit of cancellation and the carrier of command state. Commands are either
fire-and-forget (the runtime doesn't await them) or self-managed waiting (they
resolve their own promise when a condition is met); both get a run context and
cancel path. Supersede groups cancel a running command when another in the
group starts (e.g. a camera group so a second focus cancels the first).
Context: The user wants to script interaction sequences — focus, caption,
title, highlight, tap-to-advance, move, camera away. The state store and
render layer already exist; what's missing is a way to drive them over time
and react to input. Design: bgm-commands.md.
Alternatives considered: A single monolithic script interpreter. Rejected — commands as self-contained async units are testable in isolation and let the runtime stay a thin orchestrator.
D20 — Tap interaction reports every tap with the nearest trigger point
Decision: Only tap interaction is supported. A tap on a part is reported
to the command layer as a TapEvent carrying the part, the tap position in
the part's local frame, and the nearest trigger point within its radius (or
null on a miss). Trigger points are authored in the part's local frame with
mm radius; distance is measured in the part's plane; ties go to the first
declared. The command decides how to react to a miss — resolve, reject, or
ignore.
Context: Commands need to wait on player input (wait: tap). Reporting
every tap with the nearest trigger point keeps the runtime dumb and lets the
command own the UX (e.g. a "wrong spot" shake). Authoring trigger points in
the part's local frame keeps them valid as the part moves, rotates, and
flips.
Alternatives considered: Reporting only a hit and silently dropping misses. Rejected — a command that needs to react to a wrong tap has no way to do so. World-space trigger points. Rejected — they break when the part moves.