Files
tts-workshop/docs/decisions.md
T
hypercross 634a99dd25 docs: design command execution for tabletop scripts
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.
2026-08-10 15:41:43 +08:00

14 KiB

Decisions

Scope: The rationale behind key design decisions. For the system's architecture, see architecture.md. For the concrete build plan, see implementation-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.