Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b3eaed78 | ||
|
|
23332ab786 | ||
|
|
aab0b66ee8 | ||
|
|
c41c266ac1 | ||
|
|
634a99dd25 | ||
|
|
9b5223686e | ||
|
|
3aa48058f7 | ||
|
|
812b4640e2 | ||
|
|
9d776771b1 | ||
|
|
d663f4afea | ||
|
|
aa23e93b3f | ||
|
|
77e0751554 | ||
|
|
dc721d5c2d | ||
|
|
96c299e498 | ||
|
|
c2693277ac | ||
|
|
bc418b2c73 |
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: commit-conventions
|
||||||
|
description: How to write git commits for this repository. Use when committing changes, writing commit messages, or splitting work into commits. Covers conventional commit format and separating commits by concern.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit Conventions
|
||||||
|
|
||||||
|
Follow these rules whenever you create a commit in this repository.
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
Write every commit message using the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **type**: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `style`.
|
||||||
|
- **scope** (optional): the area of the codebase the change touches. Use the workspace package or directory name when it's clear (e.g. `proxy`, `web`, `packages`). Omit when the change spans multiple areas.
|
||||||
|
- **subject**: imperative mood, capitalized, no trailing period, ≤ 50 characters.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(proxy): add TTS streaming endpoint
|
||||||
|
fix(web): handle empty transcript in player
|
||||||
|
docs: document workspace layout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Separate Commits by Concern
|
||||||
|
|
||||||
|
Do not bundle unrelated changes into a single commit. Split work so each commit is a focused, self-contained unit:
|
||||||
|
|
||||||
|
- One logical change per commit (a feature, a fix, a refactor, a doc update).
|
||||||
|
- Keep each commit buildable and independently reviewable.
|
||||||
|
- Separate concerns that have different types, scopes, or reasons to be reverted independently.
|
||||||
|
- If a change is large, split it into a series of smaller commits that each stand on their own.
|
||||||
|
|
||||||
|
## Message Body
|
||||||
|
|
||||||
|
Include a body only when it adds useful context beyond the subject. If the subject fully captures the change, omit it.
|
||||||
|
|
||||||
|
- Separate the subject from the body with a blank line.
|
||||||
|
- Wrap the body at 72 characters.
|
||||||
|
- Explain the *why* and *what* rather than restating the code.
|
||||||
|
- Do not repeat information already in the subject line.
|
||||||
|
- Use `BREAKING CHANGE:` in the body (or `!` after the type/scope) for breaking changes.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Creating a new commit.
|
||||||
|
- Writing or revising a commit message.
|
||||||
|
- Deciding how to split staged or unstaged changes into commits.
|
||||||
@@ -56,6 +56,9 @@ export default function TabletopScene({
|
|||||||
enablePan
|
enablePan
|
||||||
fullscreen
|
fullscreen
|
||||||
shadowScale={shadowScale}
|
shadowScale={shadowScale}
|
||||||
|
// Keep the camera above the table so face-down cards can't be peeked
|
||||||
|
// from below. π/2 clamps the polar angle at the horizon.
|
||||||
|
maxPolarAngle={Math.PI / 2}
|
||||||
overlay={
|
overlay={
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSurface((v) => !v)}
|
onClick={() => setShowSurface((v) => !v)}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
|||||||
*
|
*
|
||||||
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
||||||
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
||||||
* expands the scene to the full screen.
|
* expands the scene to the full screen. `maxPolarAngle` (radians) clamps how
|
||||||
|
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
|
||||||
|
* peeking under face-down cards.
|
||||||
*/
|
*/
|
||||||
export default function Scene({
|
export default function Scene({
|
||||||
children,
|
children,
|
||||||
@@ -25,6 +27,7 @@ export default function Scene({
|
|||||||
fullscreen = false,
|
fullscreen = false,
|
||||||
overlay,
|
overlay,
|
||||||
shadowScale = 22,
|
shadowScale = 22,
|
||||||
|
maxPolarAngle = Math.PI,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
@@ -34,6 +37,8 @@ export default function Scene({
|
|||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
shadowScale?: number;
|
shadowScale?: number;
|
||||||
|
/** Max camera polar angle in radians; defaults to unrestricted (π). */
|
||||||
|
maxPolarAngle?: number;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
@@ -93,6 +98,7 @@ export default function Scene({
|
|||||||
enablePan={enablePan}
|
enablePan={enablePan}
|
||||||
minDistance={0.01}
|
minDistance={0.01}
|
||||||
maxDistance={8}
|
maxDistance={8}
|
||||||
|
maxPolarAngle={maxPolarAngle}
|
||||||
autoRotate={autoRotate}
|
autoRotate={autoRotate}
|
||||||
makeDefault
|
makeDefault
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# bgm-commands
|
||||||
|
|
||||||
|
Command execution for [bgm](./bgm-format.md) board games, built into
|
||||||
|
[`@tts/tabletop`](./bgm-tabletop.md). A command is a unit of scripted
|
||||||
|
interaction — focus the camera, wait for a tap, move a part, show a caption —
|
||||||
|
that runs against the tabletop state store and render layer.
|
||||||
|
|
||||||
|
This doc covers **command execution**: the async lifecycle, run contexts, and
|
||||||
|
tap interaction. How commands are *declared* (the `script` role, trigger
|
||||||
|
points on parts) is a separate concern, deferred to `bgm-format.md`.
|
||||||
|
|
||||||
|
## 1. async commands
|
||||||
|
|
||||||
|
A command is an async function that returns a result. Every command ends in
|
||||||
|
one of three states:
|
||||||
|
|
||||||
|
- `ok` — completed normally.
|
||||||
|
- `cancel` — interrupted (a newer command superseded it, the user skipped, the
|
||||||
|
surface was disabled). **Not a failure.**
|
||||||
|
- `error` — genuinely failed (asset missing, bad path, a thrown exception).
|
||||||
|
|
||||||
|
`cancel` is distinct from `error`: a superseded or skipped command stops
|
||||||
|
cleanly, while a broken command surfaces loudly. The runtime treats them
|
||||||
|
differently — a script that is superseded unwinds without alarming the player,
|
||||||
|
but an `error` is reported.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CommandResult =
|
||||||
|
| { status: 'ok' }
|
||||||
|
| { status: 'cancel' }
|
||||||
|
| { status: 'error'; error: Error };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. run contexts
|
||||||
|
|
||||||
|
Each command invocation creates its own **run context**: the unit of
|
||||||
|
cancellation and the carrier of command-specific state.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CommandRun {
|
||||||
|
id: string;
|
||||||
|
command: Command;
|
||||||
|
status: 'running' | 'ok' | 'cancel' | 'error';
|
||||||
|
data: unknown; // command-specific state, e.g. a pending tap target
|
||||||
|
cancel(): void;
|
||||||
|
done: Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A command **owns its own state and its own waiting**; the runtime only
|
||||||
|
orchestrates. Its job is to start a run, track its status, cancel it when
|
||||||
|
superseded, and react to its terminal state. This keeps commands
|
||||||
|
self-contained and testable in isolation.
|
||||||
|
|
||||||
|
## 3. fire-and-forget vs self-managed waiting
|
||||||
|
|
||||||
|
Commands fall into two categories:
|
||||||
|
|
||||||
|
- **Fire-and-forget** (`focus`, `highlight`, `caption`) — start and return
|
||||||
|
`ok` immediately (or when their tween settles). The runtime does not block
|
||||||
|
on them.
|
||||||
|
- **Self-managed waiting** (`wait: tap`, a dialog) — the command resolves its
|
||||||
|
own promise when its condition is met. The runtime just awaits it.
|
||||||
|
|
||||||
|
Fire-and-forget commands still get a run context and a cancel path. A `focus`
|
||||||
|
tween superseded by a newer `focus` must be cancellable, or two cameras fight.
|
||||||
|
"Fire-and-forget" means the runtime doesn't await it, not that it has no
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
**Supersede groups** cancel a running command when another in the same group
|
||||||
|
starts. A `focus` command belongs to a `camera` group, so a second `focus`
|
||||||
|
cancels the first.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Command {
|
||||||
|
id: string;
|
||||||
|
supersede?: string; // group; starting one cancels others in it
|
||||||
|
execute(ctx: CommandContext): Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. tap interaction
|
||||||
|
|
||||||
|
Only tap interaction is supported. A tap on a part is detected and reported to
|
||||||
|
the command layer as a `TapEvent`. Parts may declare **trigger points** —
|
||||||
|
named, circular regions the author wants to be tappable.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TriggerPoint {
|
||||||
|
id: string;
|
||||||
|
position: [number, number]; // part-local frame, mm
|
||||||
|
radius: number; // mm
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TapEvent {
|
||||||
|
part: string; // package:type#id
|
||||||
|
position: [number, number]; // part-local frame, mm
|
||||||
|
trigger: TriggerPoint | null; // nearest within radius, or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Trigger points are authored in the **part's local frame** (mm, relative to
|
||||||
|
the part's origin), not world space. A part moves, rotates, and flips
|
||||||
|
(facing), so a world-space point would break the moment it moves. The tap
|
||||||
|
point is transformed into the part's local frame at tap time.
|
||||||
|
- Distance is measured in the part's plane. The reported trigger point is the
|
||||||
|
nearest one within its `radius`; ties go to the first declared.
|
||||||
|
- **Every tap on the part is reported**, with the nearest trigger point (or
|
||||||
|
`null` when none is in range). The command decides how to react — resolve,
|
||||||
|
reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the
|
||||||
|
command owns the UX.
|
||||||
|
|
||||||
|
Commands subscribe to the tap stream via the context and unsubscribe on
|
||||||
|
cancel, so a cancelled `wait: tap` never leaks a handler.
|
||||||
|
|
||||||
|
## 5. command context
|
||||||
|
|
||||||
|
The context a command receives is the handle to everything it can affect:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CommandContext {
|
||||||
|
pkg: Package;
|
||||||
|
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
||||||
|
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
||||||
|
// camera, highlight, and overlay handles are added as those subsystems land
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. where trigger points come from
|
||||||
|
|
||||||
|
The tap detector reads trigger points from a runtime map keyed by part id; it
|
||||||
|
does not care where they are declared. Declaration (on the part definition, in
|
||||||
|
a setup, or in a script) is the deferred "how to declare" half and lives in
|
||||||
|
`bgm-format.md`.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **Where commands are declared** — the `script` role and its schema
|
||||||
|
(`bgm-format.md`), deferred.
|
||||||
|
- **Animation** — a general "ease toward target placement" layer (preferred)
|
||||||
|
vs explicit per-move tweens.
|
||||||
|
- **Camera** — `CameraControls` (drei) vs hand-rolled.
|
||||||
|
- **Triggering** — does a setup reference a script to auto-run, or is a script
|
||||||
|
a separate page the player picks?
|
||||||
|
- **Narration** — pre-recorded audio assets per script, or TTS at runtime?
|
||||||
+110
-26
@@ -37,9 +37,11 @@ bruce,[]
|
|||||||
|
|
||||||
### Inline vs file
|
### Inline vs file
|
||||||
|
|
||||||
`$variants` can be a file/URL path *or* an inline CSV string. If the value
|
`$variants` can be a single source or an array of sources. Each source is a
|
||||||
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
|
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
|
||||||
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
|
This keeps the two forms self-documenting and applies the same rule to single
|
||||||
|
values and array elements alike. In YAML a block scalar (`|`) is the natural
|
||||||
|
way to write inline CSV; in JSON you'd use `\n`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
$variants: |
|
$variants: |
|
||||||
@@ -47,9 +49,21 @@ $variants: |
|
|||||||
string,string,[number;number;number;number]
|
string,string,[number;number;number;number]
|
||||||
fish,Fish,[0;0;5;2]
|
fish,Fish,[0;0;5;2]
|
||||||
grain,Grain,[1;0;5;2]
|
grain,Grain,[1;0;5;2]
|
||||||
wood,Wood,[2;0;5;2]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An array of sources concatenates their rows. This lets one part definition
|
||||||
|
pull from several CSVs with different schemas — e.g. a deck where the regular
|
||||||
|
cards share a face sheet but the jokers have their own:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
$variants:
|
||||||
|
- ./cards.csv
|
||||||
|
- ./jokers.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Each source is parsed with its own schema, and its rows extend the original
|
||||||
|
object independently.
|
||||||
|
|
||||||
### CSV conventions
|
### CSV conventions
|
||||||
|
|
||||||
CSV is parsed with `typed-csv`:
|
CSV is parsed with `typed-csv`:
|
||||||
@@ -70,32 +84,77 @@ declaration, then uses its `include` paths to find the definitions.
|
|||||||
|
|
||||||
### Code blocks as virtual files
|
### Code blocks as virtual files
|
||||||
|
|
||||||
A code block is a virtual definition file. To give it a name — so `include:`
|
A code block is a virtual definition file. Its name is derived from the
|
||||||
and `$variants` paths can resolve against it — add a `file=` segment to the
|
`role=` on its info string — `role.type.lang` — so it is discoverable by the
|
||||||
code block's info string. The name is relative to the current markdown file:
|
default `include: ./**/*.yaml` and addressable by that name:
|
||||||
|
|
||||||
````md
|
````md
|
||||||
```yaml file=parts/cargo.yaml
|
```yaml role=part.cargo
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/cargo.csv
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
- A block with `file=` is addressable by that path.
|
- `role=part.cargo` names the block `part.cargo.yaml`.
|
||||||
- A block without `file=` is auto-named `./${hash}.yaml`, where `hash`
|
- `role=surface.game#main` names it `surface.game.yaml`.
|
||||||
is derived from its content. This makes every yaml block naturally
|
- `role=package` names it `package.yaml`.
|
||||||
discoverable by the default `include: ./**/*.yaml`. Identical blocks dedupe
|
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||||
to the same hash.
|
|
||||||
- The `file=` name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
|
||||||
resolve against. When there is a real file in that path, the codeblock wins.
|
resolve against. When there is a real file in that path, the codeblock wins.
|
||||||
- `file=` implies the file type from its extension; the language tag is
|
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
|
||||||
optional and only for editor highlighting.
|
names the block `parts/cargo.yaml` regardless of its role.
|
||||||
- **Hash vs explicit `file=`:** a hashed name is for auto-discovery, not for
|
- A block without `role=` is **not** a definition — it is ignored. Discovery
|
||||||
referencing. To point at a specific yaml block by name, give it an explicit
|
is explicit: a block is a definition only when its `role=` (or, for real
|
||||||
`file=`; otherwise its name is content-derived and unstable.
|
files, its filename) declares a known `role.type`.
|
||||||
|
|
||||||
|
### role= on the info string
|
||||||
|
|
||||||
|
A block's role is declared on the info string, using the same `role.type#id`
|
||||||
|
shape as the block's identity. `type` and `id` are optional — anything not
|
||||||
|
given comes from the content (or from `$variants` rows):
|
||||||
|
|
||||||
|
````md
|
||||||
|
```yaml role=part.cargo
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- `role=part.cargo` declares a part of type `cargo`; its `id` comes from the
|
||||||
|
content or from `$variants`.
|
||||||
|
- `role=surface.game#main` declares a surface of type `game` with id `main`.
|
||||||
|
- `role=package` declares a package; it has no type.
|
||||||
|
- A `role`/`type`/`id` given on the info string **conflicts** with the same
|
||||||
|
key in the content and errors. `id` on the info string cannot combine with
|
||||||
|
`$variants`, since every row supplies its own `id`.
|
||||||
|
- A block without `role=` is not a definition — discovery is explicit (see
|
||||||
|
above).
|
||||||
|
|
||||||
|
### Real files
|
||||||
|
|
||||||
|
A real `role.type.lang` file (e.g. `part.cargo.yaml`) is a definition by its
|
||||||
|
filename, with no `role=` needed. `role` and `type` are parsed from the name;
|
||||||
|
`id` comes from the content or `$variants`. A real file and a code block with
|
||||||
|
the same name are the same definition; the code block wins.
|
||||||
|
|
||||||
|
### Duplicates
|
||||||
|
|
||||||
|
Two definitions with the same `role.type` are grouped under the same name.
|
||||||
|
They must not define the same `id` — a duplicate `type#id` errors. Blocks with
|
||||||
|
the same `role.type` but different ids are fine.
|
||||||
|
|
||||||
### include
|
### include
|
||||||
|
|
||||||
@@ -115,7 +174,8 @@ package can use a `../`-relative pattern or an absolute-from-root pattern
|
|||||||
|
|
||||||
## 3. Roles
|
## 3. Roles
|
||||||
|
|
||||||
json objects in yaml blocks are handled if they have a `role:` field for either
|
json objects in yaml blocks are handled if they are declared as a definition
|
||||||
|
by their `role=` (or, for real files, their filename) for either
|
||||||
- `package`
|
- `package`
|
||||||
- `part`
|
- `part`
|
||||||
- `surface`
|
- `surface`
|
||||||
@@ -126,6 +186,11 @@ a valid object can either be the root or in the list of the yaml block.
|
|||||||
for all roles except package, `type` and `id` are needed.
|
for all roles except package, `type` and `id` are needed.
|
||||||
`type#id` is used for identification so that combo must be unique in the package.
|
`type#id` is used for identification so that combo must be unique in the package.
|
||||||
|
|
||||||
|
A block declares its role on the info string — `role=part.cargo` is equivalent
|
||||||
|
to `role: part` + `type: cargo` in the content (see §2). A real file declares
|
||||||
|
it in its filename. The info string/filename and content must not both set the
|
||||||
|
same key.
|
||||||
|
|
||||||
### package
|
### package
|
||||||
|
|
||||||
The package is the container for a game's definitions. It is declared with a
|
The package is the container for a game's definitions. It is declared with a
|
||||||
@@ -268,19 +333,38 @@ surfaces:
|
|||||||
- board#harbor
|
- board#harbor
|
||||||
- hud#hand
|
- hud#hand
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:boat#fleet
|
- path: /dock/0
|
||||||
/deck: harbor:card
|
parts: harbor:boat#fleet
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:card
|
||||||
|
facing: back
|
||||||
|
- path: /table
|
||||||
|
parts: harbor:token#wood
|
||||||
|
facing: standing
|
||||||
```
|
```
|
||||||
|
|
||||||
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
||||||
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
||||||
enabled.
|
enabled.
|
||||||
|
|
||||||
The value on a setup path can be either a string, or a string list.
|
`setup` is an **ordered list of placements**. Each placement moves its `parts`
|
||||||
|
to its `path`, and entries are applied in order — so a part listed in a later
|
||||||
|
placement ends up on that placement's path. This makes a setup read like "deal
|
||||||
|
the deck, then move these cards to the flop".
|
||||||
|
|
||||||
The string can either be a one part string, or a type without an id.
|
`parts` can be a single part id, a bare type without an id, or a list of
|
||||||
|
either. A bare type expands to all parts of that type during game state
|
||||||
|
initialization.
|
||||||
|
|
||||||
When id is omitted, it expands to all parts in that type during game state initialization.
|
`facing` sets how the placed parts are oriented on the board, defaulting to
|
||||||
|
`face`:
|
||||||
|
|
||||||
|
- `face` — lay flat, front up, resting on the bottom face.
|
||||||
|
- `back` — lay flat, front down (flipped over), resting on the top face.
|
||||||
|
- `standing` — stand upright on the bottom edge, front texture still showing.
|
||||||
|
|
||||||
|
A part's `facing` is seeded into the game state and can change at runtime; it
|
||||||
|
only affects orientation, never the part's texture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -80,22 +80,29 @@ Source-of-truth game state per `bgm-tabletop.md` §2:
|
|||||||
```ts
|
```ts
|
||||||
interface GameState {
|
interface GameState {
|
||||||
surfaces: Record<string, boolean>; // enabled per surface id
|
surfaces: Record<string, boolean>; // enabled per surface id
|
||||||
paths: Record<string, string[]>; // path -> part list
|
parts: Record<string, PartState>; // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string; // the path key this part is on
|
||||||
|
index: number; // the part's position in its path's stack
|
||||||
|
facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- A zustand store holding `GameState`.
|
- A zustand store holding `GameState`.
|
||||||
- **Derived render state**: `game state + surface routes => map of piece id to
|
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||||
`{ surface, route, candidate, index, stackSize }``, per enabled surface.
|
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||||
Computed with a selector/memo so the render list is stable.
|
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||||
- **Assumption**: each piece id is unique within a path (documented in
|
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||||
|
- **Assumption**: each piece id is unique on the board (documented in
|
||||||
`bgm-tabletop.md`); the render map is keyed by piece id.
|
`bgm-tabletop.md`); the render map is keyed by piece id.
|
||||||
|
|
||||||
### 4. Setup seeding (`setup.ts`) ✅
|
### 4. Setup seeding (`setup.ts`) ✅
|
||||||
|
|
||||||
- `SetupLoader`: side-effect-only component that seeds the store from a
|
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||||
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
|
`Setup` — enables its `surfaces` (or all when omitted) and applies its
|
||||||
`setup` paths.
|
ordered `setup` placements (each moves its `parts` to a `path`).
|
||||||
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
||||||
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
|
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
|
||||||
game-state init concern, so it lives here).
|
game-state init concern, so it lives here).
|
||||||
@@ -165,6 +172,14 @@ consumers share them (see Open decisions).
|
|||||||
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||||
from the library (work item 2), proving it end-to-end.
|
from the library (work item 2), proving it end-to-end.
|
||||||
|
|
||||||
|
## Commands (not yet implemented)
|
||||||
|
|
||||||
|
Scripted interaction is designed in [`bgm-commands.md`](./bgm-commands.md):
|
||||||
|
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. Implementation order: types + run-context manager,
|
||||||
|
tap detection, then the first commands (`wait: tap`, `focus`).
|
||||||
|
|
||||||
## Open decisions (defaults in bold)
|
## Open decisions (defaults in bold)
|
||||||
|
|
||||||
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
||||||
|
|||||||
+17
-4
@@ -15,18 +15,25 @@ source-of-truth game state:
|
|||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
surfaces: Record<string, boolean>, // enabled per surface id
|
surfaces: Record<string, boolean>, // enabled per surface id
|
||||||
paths: Record<string, string[]>, // path -> part list
|
parts: Record<string, PartState>, // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string, // the path key this part is on
|
||||||
|
index: number, // the part's position in its path's stack
|
||||||
|
facing: 'face' | 'back' | 'standing', // how the part is oriented on the board
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous.
|
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
|
||||||
|
|
||||||
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list.
|
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, facing }` for rendering on a surface. keys of this map makes a stable render list.
|
||||||
|
|
||||||
- `route` - the matched route.
|
- `route` - the matched route.
|
||||||
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
||||||
- `index` - the piece's position in its path's stack.
|
- `index` - the piece's position in its path's stack.
|
||||||
- `stackSize` - the number of pieces on the path.
|
- `stackSize` - the number of pieces on the path.
|
||||||
|
- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`).
|
||||||
|
|
||||||
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
||||||
|
|
||||||
@@ -42,7 +49,13 @@ the render map is per enabled surface: a piece may appear on more than one enabl
|
|||||||
|
|
||||||
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||||
|
|
||||||
## 5. usage
|
## 5. commands
|
||||||
|
|
||||||
|
Scripted interaction — focus, tap-to-advance, move, caption — is built on an
|
||||||
|
async command layer. See [`bgm-commands.md`](./bgm-commands.md) for command
|
||||||
|
execution (lifecycle, run contexts, tap interaction).
|
||||||
|
|
||||||
|
## 6. usage
|
||||||
|
|
||||||
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
||||||
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
||||||
|
|||||||
@@ -260,3 +260,43 @@ boundary.
|
|||||||
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
||||||
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
||||||
geometry directly.
|
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`](./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.
|
||||||
@@ -4,8 +4,7 @@ The base game's 24 landscape tiles, laid out on a table with a draw pile and a
|
|||||||
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
||||||
standard `45×45` mm square.
|
standard `45×45` mm square.
|
||||||
|
|
||||||
```yaml file=carcassonne.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: carcassonne
|
id: carcassonne
|
||||||
title: Carcassonne
|
title: Carcassonne
|
||||||
designer: Klaus-Jürgen Wrede
|
designer: Klaus-Jürgen Wrede
|
||||||
@@ -21,9 +20,7 @@ One `tile` part per distinct tile type (A–X). All tiles share the same square
|
|||||||
face image and a uniform size; the `$variants` CSV expands them into the 24
|
face image and a uniform size; the `$variants` CSV expands them into the 24
|
||||||
tile parts.
|
tile parts.
|
||||||
|
|
||||||
```yaml file=tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
face: ./20AE_Base_Game_C2_Tile_A.png
|
face: ./20AE_Base_Game_C2_Tile_A.png
|
||||||
size: [45, 45, 3]
|
size: [45, 45, 3]
|
||||||
fillet: 1
|
fillet: 1
|
||||||
@@ -65,10 +62,8 @@ A table with a draw pile on the left and an `11×11` grid of placed tiles in the
|
|||||||
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
||||||
so tiles sit edge to edge.
|
so tiles sit edge to edge.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: board
|
id: board
|
||||||
role: surface
|
|
||||||
size: [600, 600]
|
size: [600, 600]
|
||||||
layout:
|
layout:
|
||||||
- route: /draw
|
- route: /draw
|
||||||
@@ -215,15 +210,17 @@ string,string,number,number,number
|
|||||||
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
|
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
|
||||||
every tile of that type), then seed the grid with a few opening tiles.
|
every tile of that type), then seed the grid with a few opening tiles.
|
||||||
|
|
||||||
```yaml file=main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#board
|
- board#board
|
||||||
setup:
|
setup:
|
||||||
/draw: carcassonne:tile
|
- path: /draw
|
||||||
/grid/5/5: carcassonne:tile#a
|
parts: carcassonne:tile
|
||||||
/grid/5/6: carcassonne:tile#b
|
- path: /grid/5/5
|
||||||
/grid/6/5: carcassonne:tile#c
|
parts: carcassonne:tile#a
|
||||||
|
- path: /grid/5/6
|
||||||
|
parts: carcassonne:tile#b
|
||||||
|
- path: /grid/6/5
|
||||||
|
parts: carcassonne:tile#c
|
||||||
```
|
```
|
||||||
+22
-17
@@ -4,8 +4,7 @@ A standard 52-card poker deck, laid out on a table with a draw pile and
|
|||||||
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
||||||
generate a full deck from a single part definition.
|
generate a full deck from a single part definition.
|
||||||
|
|
||||||
```yaml file=poker.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: poker
|
id: poker
|
||||||
title: Poker
|
title: Poker
|
||||||
designer: Public Domain
|
designer: Public Domain
|
||||||
@@ -21,9 +20,7 @@ picks a cell from the `13×4` face sheet (`cards-13x4.jpg`) — 13 ranks across,
|
|||||||
4 suits down. All cards share the same back from the `4×1` back sheet
|
4 suits down. All cards share the same back from the `4×1` back sheet
|
||||||
(`back-4x1.png`).
|
(`back-4x1.png`).
|
||||||
|
|
||||||
```yaml file=cards.yaml
|
```yaml role=part.card
|
||||||
role: part
|
|
||||||
type: card
|
|
||||||
face: ./cards-13x4.jpg
|
face: ./cards-13x4.jpg
|
||||||
back: ./back-4x1.png
|
back: ./back-4x1.png
|
||||||
size: [63, 88, 0.3]
|
size: [63, 88, 0.3]
|
||||||
@@ -93,10 +90,8 @@ ac,A,clubs,[12;3;13;4],[0;0;4;1]
|
|||||||
A table with a draw pile on the left and five community-card slots across the
|
A table with a draw pile on the left and five community-card slots across the
|
||||||
middle. The deck pile fans its stacked cards along a curve.
|
middle. The deck pile fans its stacked cards along a curve.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: poker
|
id: poker
|
||||||
role: surface
|
|
||||||
size: [600, 400]
|
size: [600, 400]
|
||||||
layout:
|
layout:
|
||||||
- route: /deck
|
- route: /deck
|
||||||
@@ -125,16 +120,26 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
|
Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
|
||||||
that type), then flip a flop onto the community slots.
|
card of that type, `facing: back`), then flip a flop onto the community slots
|
||||||
|
and stand a couple of drawn cards on their bottom edge.
|
||||||
|
|
||||||
```yaml file=main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/deck: poker:card
|
- path: /deck
|
||||||
/community/0: poker:card#as
|
parts: poker:card
|
||||||
/community/1: poker:card#kh
|
facing: back
|
||||||
/community/2: poker:card#7d
|
- path: /community/0
|
||||||
|
parts: poker:card#as
|
||||||
|
- path: /community/1
|
||||||
|
parts: poker:card#kh
|
||||||
|
- path: /community/2
|
||||||
|
parts: poker:card#7d
|
||||||
|
- path: /community/3
|
||||||
|
parts: poker:card#jc
|
||||||
|
facing: standing
|
||||||
|
- path: /community/4
|
||||||
|
parts: poker:card#2h
|
||||||
|
facing: standing
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
A tiny example game used to exercise the bgm loader.
|
A tiny example game used to exercise the bgm loader.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,9 +24,7 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: grain
|
id: grain
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [0, 0, 5, 2]
|
faceCrop: [0, 0, 5, 2]
|
||||||
@@ -41,10 +37,8 @@ fillet: 2
|
|||||||
|
|
||||||
## Board
|
## Board
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: table
|
kind: table
|
||||||
@@ -63,10 +57,8 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/player.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: player
|
id: player
|
||||||
role: surface
|
|
||||||
size: [200, 200]
|
size: [200, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: child
|
kind: child
|
||||||
@@ -79,14 +71,14 @@ layout:
|
|||||||
$variants: ./hand.csv
|
$variants: ./hand.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/hand.csv
|
```csv file=hand.csv
|
||||||
slot,x,y,rotation
|
slot,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,0,0,0
|
0,0,0,0
|
||||||
1,0,20,0
|
1,0,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
@@ -95,14 +87,14 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#harbor
|
- board#harbor
|
||||||
- board#player
|
- board#player
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:token#wood
|
- path: /dock/0
|
||||||
/deck: harbor:token#grain
|
parts: harbor:token#wood
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:token#grain
|
||||||
```
|
```
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
A second tiny example game, used to exercise the loader's collection of
|
A second tiny example game, used to exercise the loader's collection of
|
||||||
multiple packages.
|
multiple packages.
|
||||||
|
|
||||||
```yaml file=azul.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: azul
|
id: azul
|
||||||
title: Azul
|
title: Azul
|
||||||
designer: Michael Kiesling
|
designer: Michael Kiesling
|
||||||
@@ -13,9 +12,7 @@ language: en
|
|||||||
include: ['./**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
id: blue
|
id: blue
|
||||||
face: ./assets/tiles.png
|
face: ./assets/tiles.png
|
||||||
faceCrop: [0, 0, 5, 5]
|
faceCrop: [0, 0, 5, 5]
|
||||||
@@ -23,10 +20,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 1
|
fillet: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: azul
|
id: azul
|
||||||
role: surface
|
|
||||||
size: [400, 300]
|
size: [400, 300]
|
||||||
layout:
|
layout:
|
||||||
- route: /factory/:n
|
- route: /factory/:n
|
||||||
@@ -34,7 +29,7 @@ layout:
|
|||||||
$variants: ./factories.csv
|
$variants: ./factories.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/factories.csv
|
```csv file=factories.csv
|
||||||
n,x,y,rotation
|
n,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,-150,0,0
|
0,-150,0,0
|
||||||
@@ -43,10 +38,9 @@ string,number,number,number
|
|||||||
3,150,0,0
|
3,150,0,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/factory/0: azul:tile#blue
|
- path: /factory/0
|
||||||
|
parts: azul:tile#blue
|
||||||
```
|
```
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
A tiny example game used to exercise the bgm loader end-to-end through a real
|
A tiny example game used to exercise the bgm loader end-to-end through a real
|
||||||
vite build.
|
vite build.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
@@ -13,9 +12,7 @@ language: en
|
|||||||
include: ['./**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,10 +23,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
layout:
|
layout:
|
||||||
- route: /dock/:seat
|
- route: /dock/:seat
|
||||||
@@ -41,18 +36,18 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
1,40,20,0
|
1,40,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:token#wood
|
- path: /dock/0
|
||||||
/deck: harbor:token#grain
|
parts: harbor:token#wood
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:token#grain
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('collectPackages', () => {
|
|||||||
const harbor = packages[0]!;
|
const harbor = packages[0]!;
|
||||||
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
||||||
|
|
||||||
// Two tokens from two yaml blocks sharing a `file=` name.
|
// Two tokens from two yaml blocks sharing a `role=part.token` name.
|
||||||
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
||||||
const wood = harbor.parts.get('token#wood')!;
|
const wood = harbor.parts.get('token#wood')!;
|
||||||
expect(wood).toMatchObject({
|
expect(wood).toMatchObject({
|
||||||
@@ -26,9 +26,9 @@ describe('collectPackages', () => {
|
|||||||
});
|
});
|
||||||
expect(wood.face).toBe('./assets/tokens.png');
|
expect(wood.face).toBe('./assets/tokens.png');
|
||||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||||
// Relative assets resolve against the source file's directory. The fixture
|
// Relative assets resolve against the markdown file's directory. The
|
||||||
// markdown sits at the games root, so the virtual file is `parts/tokens.yaml`.
|
// fixture markdown sits at the games root, so baseUrl is empty.
|
||||||
expect(wood.baseUrl).toBe('parts/');
|
expect(wood.baseUrl).toBe('');
|
||||||
|
|
||||||
// Two surfaces: the table board and its child player board.
|
// Two surfaces: the table board and its child player board.
|
||||||
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
||||||
@@ -55,10 +55,10 @@ describe('collectPackages', () => {
|
|||||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
||||||
const setup = harbor.setups.get('game#main')!;
|
const setup = harbor.setups.get('game#main')!;
|
||||||
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
|
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
|
||||||
expect(setup.setup).toEqual({
|
expect(setup.setup).toEqual([
|
||||||
'/dock/0': 'harbor:token#wood',
|
{ path: '/dock/0', parts: 'harbor:token#wood' },
|
||||||
'/deck': 'harbor:token#grain',
|
{ path: '/deck', parts: 'harbor:token#grain' },
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes include patterns to the package declaration directory', () => {
|
it('scopes include patterns to the package declaration directory', () => {
|
||||||
@@ -81,9 +81,23 @@ describe('collectPackages', () => {
|
|||||||
it('throws on a duplicate type#id', () => {
|
it('throws on a duplicate type#id', () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs('', fixtureRoot);
|
||||||
// Inject a duplicate part into the map under a new file name.
|
// Inject a duplicate part into the map under a new file name.
|
||||||
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('parts/tokens.yaml'))!;
|
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!;
|
||||||
const tokens = defMap.defs.get(tokensKey)!;
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
|
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [tokens[0]!]);
|
||||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when an info-string id combines with $variants', () => {
|
||||||
|
const defMap = loadDefs('', fixtureRoot);
|
||||||
|
// A part whose `id` comes from $variants rows, but with an info-string id.
|
||||||
|
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!;
|
||||||
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
|
const variant = {
|
||||||
|
...tokens[0]!,
|
||||||
|
value: { ...tokens[0]!.value, id: undefined, $variants: './seats.csv' },
|
||||||
|
role: { role: 'part', type: 'token', id: 'wood' },
|
||||||
|
};
|
||||||
|
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [variant]);
|
||||||
|
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/can't combine with \$variants/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+32
-16
@@ -21,6 +21,7 @@ import { validatePackage, validatePart, validateSetup, validateSurface } from '.
|
|||||||
import { expandVariants } from './variants.js';
|
import { expandVariants } from './variants.js';
|
||||||
import {
|
import {
|
||||||
BgmError,
|
BgmError,
|
||||||
|
ROLES,
|
||||||
type DefFile,
|
type DefFile,
|
||||||
type ParsedDef,
|
type ParsedDef,
|
||||||
type Package,
|
type Package,
|
||||||
@@ -31,8 +32,6 @@ import {
|
|||||||
type Surface,
|
type Surface,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
|
|
||||||
|
|
||||||
/** Every definition parsed from a def file, keyed by its path-style name. */
|
/** Every definition parsed from a def file, keyed by its path-style name. */
|
||||||
export interface DefMap {
|
export interface DefMap {
|
||||||
/** All def files (real + virtual), keyed by name. */
|
/** All def files (real + virtual), keyed by name. */
|
||||||
@@ -145,9 +144,9 @@ class PackageAcc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Expand `$variants` on a def object into a list of concrete objects. */
|
/** Expand `$variants` on a def object into a list of concrete objects. */
|
||||||
private expand(obj: Record<string, unknown>, baseName: string, source: string): Record<string, unknown>[] {
|
private expand(obj: Record<string, unknown>, baseDir: string, source: string): Record<string, unknown>[] {
|
||||||
if (!('$variants' in obj)) return [obj];
|
if (!('$variants' in obj)) return [obj];
|
||||||
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
|
const rows = expandVariants(obj['$variants'], baseNameFor(baseDir), this.defs.files, source);
|
||||||
const { $variants: _v, ...base } = obj;
|
const { $variants: _v, ...base } = obj;
|
||||||
return rows.map((row) => ({ ...base, ...row }));
|
return rows.map((row) => ({ ...base, ...row }));
|
||||||
}
|
}
|
||||||
@@ -162,9 +161,15 @@ class PackageAcc {
|
|||||||
// patterns are prefixed to match.
|
// patterns are prefixed to match.
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
|
// A leading `/` marks a pattern as root-relative. Otherwise it's
|
||||||
|
// relative to the package declaration's directory. When the package is
|
||||||
|
// at the games root (empty baseDir), the pattern has no leading slash:
|
||||||
|
// `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not.
|
||||||
const resolved = pattern.startsWith('/')
|
const resolved = pattern.startsWith('/')
|
||||||
? pattern
|
? pattern
|
||||||
: `/${path.posix.join(this.baseDir, pattern)}`;
|
: this.baseDir
|
||||||
|
? `/${path.posix.join(this.baseDir, pattern)}`
|
||||||
|
: path.posix.join(this.baseDir, pattern);
|
||||||
const matcher = picomatch(resolved, { dot: true });
|
const matcher = picomatch(resolved, { dot: true });
|
||||||
for (const name of this.defs.defs.keys()) {
|
for (const name of this.defs.defs.keys()) {
|
||||||
if (matcher(name)) names.add(name);
|
if (matcher(name)) names.add(name);
|
||||||
@@ -174,11 +179,16 @@ class PackageAcc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private add(role: Role, def: ParsedDef, fileName: string) {
|
private add(role: Role, def: ParsedDef, fileName: string) {
|
||||||
const expanded = this.expand(def.value, def.file, def.source);
|
// `id` on the info string can't combine with `$variants`, since every
|
||||||
|
// row supplies its own `id` and would override it.
|
||||||
|
if (def.role?.id && '$variants' in def.value) {
|
||||||
|
throw new BgmError(`id on the info string can't combine with $variants`, fileName);
|
||||||
|
}
|
||||||
|
const expanded = this.expand(def.value, def.baseDir ?? '', def.source);
|
||||||
for (const obj of expanded) {
|
for (const obj of expanded) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'part': {
|
case 'part': {
|
||||||
const part = asPart(obj, fileName);
|
const part = asPart(obj, def.baseDir ?? '');
|
||||||
const key = `${part.type}#${part.id}`;
|
const key = `${part.type}#${part.id}`;
|
||||||
if (this.parts.has(key)) {
|
if (this.parts.has(key)) {
|
||||||
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
||||||
@@ -187,7 +197,7 @@ class PackageAcc {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'surface': {
|
case 'surface': {
|
||||||
const surface = asSurface(obj, fileName, this.defs.files);
|
const surface = asSurface(obj, def.baseDir ?? '', this.defs.files);
|
||||||
const key = `${surface.type}#${surface.id}`;
|
const key = `${surface.type}#${surface.id}`;
|
||||||
if (this.surfaces.has(key)) {
|
if (this.surfaces.has(key)) {
|
||||||
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
||||||
@@ -227,23 +237,29 @@ function asPackage(def: ParsedDef, source: string): PackageDef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asPart(obj: Record<string, unknown>, source: string): Part {
|
/** A base name whose directory is `baseDir`, for resolving `$variants` paths. */
|
||||||
|
function baseNameFor(baseDir: string): string {
|
||||||
|
const dir = baseDir.replace(/^\/+/, '');
|
||||||
|
return dir ? `/${dir}/def.yaml` : '/def.yaml';
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPart(obj: Record<string, unknown>, baseDir: string): Part {
|
||||||
try {
|
try {
|
||||||
const part = validatePart(obj) as unknown as Part;
|
const part = validatePart(obj) as unknown as Part;
|
||||||
// Resolve relative asset paths against the directory of the source file
|
// Resolve relative asset paths against the directory of the source file
|
||||||
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
// (path-style name relative to the games root). For a code block this is
|
||||||
// Real files may carry a leading slash from an empty root; strip it.
|
// the markdown file's directory; for a real file, its own directory.
|
||||||
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
const dir = baseDir.replace(/^\/+/, '');
|
||||||
part.baseUrl = dir ? `${dir}/` : '';
|
part.baseUrl = dir ? `${dir}/` : '';
|
||||||
return part;
|
return part;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSurface(
|
function asSurface(
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
source: string,
|
baseDir: string,
|
||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
): Surface {
|
): Surface {
|
||||||
const value: Record<string, unknown> = { ...obj };
|
const value: Record<string, unknown> = { ...obj };
|
||||||
@@ -256,7 +272,7 @@ function asSurface(
|
|||||||
const r = route as Record<string, unknown>;
|
const r = route as Record<string, unknown>;
|
||||||
const cand = r['candidates'];
|
const cand = r['candidates'];
|
||||||
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
||||||
const rows = expandVariants(cand['$variants'], source, defs, source);
|
const rows = expandVariants(cand['$variants'], baseNameFor(baseDir), defs, baseDir);
|
||||||
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
||||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||||
}
|
}
|
||||||
@@ -266,7 +282,7 @@ function asSurface(
|
|||||||
try {
|
try {
|
||||||
return validateSurface(value) as unknown as Surface;
|
return validateSurface(value) as unknown as Surface;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, baseDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { scanMarkdown } from './markdown.js';
|
import { scanMarkdown } from './markdown.js';
|
||||||
|
|
||||||
describe('scanMarkdown', () => {
|
describe('scanMarkdown', () => {
|
||||||
it('extracts a fenced code block with a file= name', () => {
|
it('names a block from its role= as role.type.lang', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'# Title',
|
'# Title',
|
||||||
'',
|
'',
|
||||||
'```yaml file=parts/cargo.yaml',
|
'```yaml role=part.cargo',
|
||||||
'role: part',
|
'id: wood',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'text after',
|
'text after',
|
||||||
@@ -17,26 +17,70 @@ describe('scanMarkdown', () => {
|
|||||||
|
|
||||||
expect(fences).toHaveLength(1);
|
expect(fences).toHaveLength(1);
|
||||||
expect(fences[0]).toMatchObject({
|
expect(fences[0]).toMatchObject({
|
||||||
info: 'yaml file=parts/cargo.yaml',
|
info: 'yaml role=part.cargo',
|
||||||
content: 'role: part',
|
content: 'id: wood',
|
||||||
startLine: 3,
|
startLine: 3,
|
||||||
endLine: 5,
|
endLine: 5,
|
||||||
});
|
});
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0]).toMatchObject({
|
expect(files[0]).toMatchObject({
|
||||||
name: 'harbor/parts/cargo.yaml',
|
name: 'harbor/part.cargo.yaml',
|
||||||
kind: 'yaml',
|
kind: 'yaml',
|
||||||
text: 'role: part',
|
text: 'id: wood',
|
||||||
source: 'harbor/harbor.md:3-5',
|
source: 'harbor/harbor.md:3-5',
|
||||||
|
role: { role: 'part', type: 'cargo' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-names a block without file= from its content hash', () => {
|
it('names a package block package.yaml', () => {
|
||||||
|
const md = '```yaml role=package\nid: harbor\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
|
expect(files[0]).toMatchObject({ name: 'harbor/package.yaml', role: { role: 'package' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a surface block with type and id', () => {
|
||||||
|
const md = '```yaml role=surface.game#main\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'poker/poker.md');
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
name: 'poker/surface.game.yaml',
|
||||||
|
role: { role: 'surface', type: 'game', id: 'main' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses file= to override the role.type name', () => {
|
||||||
|
const md = '```yaml file=parts/cargo.yaml role=part.cargo\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
name: 'harbor/parts/cargo.yaml',
|
||||||
|
role: { role: 'part', type: 'cargo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a block without role= or file=', () => {
|
||||||
const md = '```yaml\nrole: part\n```';
|
const md = '```yaml\nrole: part\n```';
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(0);
|
||||||
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
|
});
|
||||||
expect(files[0]!.kind).toBe('yaml');
|
|
||||||
|
it('names a csv block with file= as csv', () => {
|
||||||
|
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
|
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an unknown role=', () => {
|
||||||
|
const md = '```yaml role=widget\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Invalid role/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on role=package with a type', () => {
|
||||||
|
const md = '```yaml role=package.foo\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Package role takes no type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a role without a type', () => {
|
||||||
|
const md = '```yaml role=part\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/requires a type/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-definition languages', () => {
|
it('ignores non-definition languages', () => {
|
||||||
@@ -52,23 +96,15 @@ describe('scanMarkdown', () => {
|
|||||||
expect(files).toHaveLength(0);
|
expect(files).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names a csv block with file= as csv', () => {
|
|
||||||
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
|
||||||
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks line numbers across multiple blocks', () => {
|
it('tracks line numbers across multiple blocks', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'```yaml file=a.yaml',
|
'```yaml role=part.a',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'```yaml file=b.yaml',
|
'```yaml role=part.b',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(fences.map((f) => f.startLine)).toEqual([1, 5]);
|
expect(fences.map((f) => f.startLine)).toEqual([1, 4]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Extract virtual definition files from markdown code blocks.
|
* Extract virtual definition files from markdown code blocks.
|
||||||
*
|
*
|
||||||
* Each fenced code block is a virtual definition file:
|
* Each fenced code block is a virtual definition file. A block is a
|
||||||
* - With a `file=` segment in its info string, named relative to the
|
* definition only when its info string declares a `role=` (or a `file=`):
|
||||||
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
|
* - With `role=part.cargo`, named `part.cargo.yaml` (the `role.type.lang`
|
||||||
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
|
* form), discoverable by the default include pattern (all yaml in the
|
||||||
* block is discoverable by the default include pattern (all yaml in the
|
* same and sub folders).
|
||||||
* same and sub folders). Identical blocks dedupe to the same hash.
|
* - With `file=parts/cargo.yaml`, named that path regardless of its role.
|
||||||
|
* - Without either, the block is not a definition and is ignored.
|
||||||
*
|
*
|
||||||
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
||||||
* virtual file.
|
* virtual file.
|
||||||
*/
|
*/
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { posix } from 'node:path';
|
import { posix } from 'node:path';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { BgmError, type DefFile } from './types.js';
|
import { BgmError, ROLES, roleToName, type DefFile, type Role, type RoleMeta } from './types.js';
|
||||||
|
|
||||||
/** The languages that count as definition files; others are ignored. */
|
/** The languages that count as definition files; others are ignored. */
|
||||||
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
||||||
@@ -25,7 +25,7 @@ export interface Fence {
|
|||||||
startLine: number;
|
startLine: number;
|
||||||
/** Line number (1-based) of the closing fence. */
|
/** Line number (1-based) of the closing fence. */
|
||||||
endLine: number;
|
endLine: number;
|
||||||
/** The info string content (e.g. `yaml file=parts/cargo.yaml`). */
|
/** The info string content (e.g. `yaml role=part.cargo`). */
|
||||||
info: string;
|
info: string;
|
||||||
/** The code block's content (without the fences). */
|
/** The code block's content (without the fences). */
|
||||||
content: string;
|
content: string;
|
||||||
@@ -60,13 +60,17 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
|
|
||||||
fences.push({ startLine, endLine, info, content: token.text });
|
fences.push({ startLine, endLine, info, content: token.text });
|
||||||
|
|
||||||
const name = parseInfo(info);
|
const parsed = parseInfo(info, `${sourcePath}:${startLine}`);
|
||||||
if (name) {
|
if (parsed) {
|
||||||
files.push({
|
files.push({
|
||||||
name: posix.join(posix.dirname(sourcePath), name),
|
name: posix.join(posix.dirname(sourcePath), parsed.name),
|
||||||
text: token.text,
|
text: token.text,
|
||||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
source: `${sourcePath}:${startLine}-${endLine}`,
|
||||||
kind: kindOf(name),
|
kind: parsed.kind,
|
||||||
|
role: parsed.role,
|
||||||
|
// Relative asset paths resolve against the markdown file's directory,
|
||||||
|
// not the virtual `role.type` name (which has no directory).
|
||||||
|
baseDir: posix.dirname(sourcePath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,17 +78,54 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
return { fences, files };
|
return { fences, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ParsedInfo {
|
||||||
* Parse a fence's info string for a `file=` segment and derive the virtual
|
name: string;
|
||||||
* file name. Blocks without `file=` are auto-named from their content hash.
|
kind: DefFile['kind'];
|
||||||
*/
|
role?: RoleMeta;
|
||||||
function parseInfo(info: string): string | null {
|
}
|
||||||
const fileMatch = /file=(\S+)/.exec(info);
|
|
||||||
if (fileMatch) return fileMatch[1]!;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a fence's info string into a virtual file name and role metadata.
|
||||||
|
* A block is a definition when it has a `role=` or a `file=`. `file=`
|
||||||
|
* overrides the auto `role.type.lang` name.
|
||||||
|
*/
|
||||||
|
function parseInfo(info: string, source: string): ParsedInfo | null {
|
||||||
const lang = info.split(/\s+/)[0];
|
const lang = info.split(/\s+/)[0];
|
||||||
if (!lang || !DEF_LANGS.has(lang)) return null;
|
const fileMatch = /file=(\S+)/.exec(info);
|
||||||
return `./${hash(info)}.yaml`;
|
const role = parseRole(info, source);
|
||||||
|
|
||||||
|
if (fileMatch) {
|
||||||
|
const name = fileMatch[1]!;
|
||||||
|
return { name, kind: kindOf(name), role };
|
||||||
|
}
|
||||||
|
if (!role) return null;
|
||||||
|
if (!lang || !DEF_LANGS.has(lang)) {
|
||||||
|
throw new BgmError(`Definition block needs a definition language tag`, source);
|
||||||
|
}
|
||||||
|
return { name: roleToName(role, extOf(lang)), kind: kindOfLang(lang), role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a fence's info string for a `role=` segment into role metadata.
|
||||||
|
* `role=part.cargo` -> `{ role: 'part', type: 'cargo' }`;
|
||||||
|
* `role=surface.game#main` -> `{ role: 'surface', type: 'game', id: 'main' }`;
|
||||||
|
* `role=package` -> `{ role: 'package' }`. Returns `undefined` when absent.
|
||||||
|
*/
|
||||||
|
function parseRole(info: string, source: string): RoleMeta | undefined {
|
||||||
|
const match = /role=(\S+)/.exec(info);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const spec = match[1]!;
|
||||||
|
const [role, rest] = spec.split('.');
|
||||||
|
if (!role || !ROLES.has(role as Role)) {
|
||||||
|
throw new BgmError(`Invalid role "${spec}"`, source);
|
||||||
|
}
|
||||||
|
if (role === 'package') {
|
||||||
|
if (rest) throw new BgmError(`Package role takes no type or id`, source);
|
||||||
|
return { role: 'package' };
|
||||||
|
}
|
||||||
|
const [type, id] = (rest ?? '').split('#');
|
||||||
|
if (!type) throw new BgmError(`Role "${role}" requires a type`, source);
|
||||||
|
return { role: role as Role, type, id: id || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive the def file type from its name's extension. */
|
/** Derive the def file type from its name's extension. */
|
||||||
@@ -96,9 +137,21 @@ function kindOf(name: string): DefFile['kind'] {
|
|||||||
return 'yaml';
|
return 'yaml';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stable content hash for auto-named blocks. */
|
/** The file extension for a definition language tag. */
|
||||||
function hash(text: string): string {
|
function extOf(lang: string): string {
|
||||||
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
|
return lang === 'yml' ? 'yml' : lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The def file kind for a definition language tag. */
|
||||||
|
function kindOfLang(lang: string): DefFile['kind'] {
|
||||||
|
switch (lang) {
|
||||||
|
case 'json':
|
||||||
|
return 'json';
|
||||||
|
case 'toml':
|
||||||
|
return 'toml';
|
||||||
|
default:
|
||||||
|
return 'yaml';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 1-based line number where `raw` starts within `text`. */
|
/** The 1-based line number where `raw` starts within `text`. */
|
||||||
@@ -110,9 +163,8 @@ function lineOf(text: string, raw: string): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
||||||
* Multiple blocks may share a name (e.g. several `file=parts/tokens.yaml`
|
* Multiple blocks may share a name (e.g. several `role=part.token` blocks);
|
||||||
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
|
* each is kept as a separate entry.
|
||||||
* same hash name.
|
|
||||||
*/
|
*/
|
||||||
export type VirtualFiles = Map<string, DefFile[]>;
|
export type VirtualFiles = Map<string, DefFile[]>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseDefText } from './parse.js';
|
||||||
|
import type { DefFile } from './types.js';
|
||||||
|
|
||||||
|
function file(overrides: Partial<DefFile> = {}): DefFile {
|
||||||
|
return {
|
||||||
|
name: 'poker/cards.yaml',
|
||||||
|
text: 'id: 2s',
|
||||||
|
source: 'poker/poker.md:3-5',
|
||||||
|
kind: 'yaml',
|
||||||
|
baseDir: 'poker',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseDefText', () => {
|
||||||
|
it('merges role= metadata into the parsed object', () => {
|
||||||
|
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
|
||||||
|
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'poker-card', id: '2s' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges role= with type and id', () => {
|
||||||
|
const defs = parseDefText(
|
||||||
|
file({ text: 'id: main', role: { role: 'surface', type: 'game', id: 'main' } }),
|
||||||
|
);
|
||||||
|
expect(defs[0]!.value).toMatchObject({ role: 'surface', type: 'game', id: 'main' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges role= into every item of a list block', () => {
|
||||||
|
const defs = parseDefText(
|
||||||
|
file({
|
||||||
|
text: '- id: a\n- id: b',
|
||||||
|
role: { role: 'part', type: 'tile' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(defs.map((d) => d.value)).toEqual([
|
||||||
|
{ role: 'part', type: 'tile', id: 'a' },
|
||||||
|
{ role: 'part', type: 'tile', id: 'b' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the role metadata on the parsed def', () => {
|
||||||
|
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
|
||||||
|
expect(defs[0]!.role).toEqual({ role: 'part', type: 'poker-card' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the base directory on the parsed def', () => {
|
||||||
|
const defs = parseDefText(file({ baseDir: 'poker/parts' }));
|
||||||
|
expect(defs[0]!.baseDir).toBe('poker/parts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on a role conflict with the content', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDefText(file({ text: 'role: surface', role: { role: 'part' } })),
|
||||||
|
).toThrow(/Conflicting role/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on a type conflict with the content', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDefText(file({ text: 'type: tile', role: { role: 'part', type: 'card' } })),
|
||||||
|
).toThrow(/Conflicting type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on an id conflict with the content', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDefText(file({ text: 'id: 2s', role: { role: 'part', type: 'card', id: '3s' } })),
|
||||||
|
).toThrow(/Conflicting id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts matching role metadata and content', () => {
|
||||||
|
const defs = parseDefText(
|
||||||
|
file({ text: 'role: part\ntype: card', role: { role: 'part', type: 'card' } }),
|
||||||
|
);
|
||||||
|
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'card' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the object unchanged without role metadata', () => {
|
||||||
|
const defs = parseDefText(file({ text: 'role: part\ntype: card\nid: 2s' }));
|
||||||
|
expect(defs[0]!.value).toEqual({ role: 'part', type: 'card', id: '2s' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,15 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { parse as parseToml } from 'smol-toml';
|
import { parse as parseToml } from 'smol-toml';
|
||||||
import { BgmError, type DefFile, type ParsedDef } from './types.js';
|
import { BgmError, roleFromName, type DefFile, type ParsedDef } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a def file's text into a list of definition objects.
|
* Parse a def file's text into a list of definition objects.
|
||||||
*
|
*
|
||||||
|
* A code block's `role=` (or a real file's `role.type` name) metadata is
|
||||||
|
* merged into every parsed object, erroring on a conflict with the same key
|
||||||
|
* in the content.
|
||||||
|
*
|
||||||
* @returns the parsed objects; the root object (index `-1`) or the list
|
* @returns the parsed objects; the root object (index `-1`) or the list
|
||||||
* items (index `0..n`)
|
* items (index `0..n`)
|
||||||
*/
|
*/
|
||||||
@@ -33,7 +37,8 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
|
|
||||||
const push = (value: unknown, index: number) => {
|
const push = (value: unknown, index: number) => {
|
||||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source });
|
const merged = mergeRole(file, value as Record<string, unknown>);
|
||||||
|
out.push({ file: file.name, index, value: merged, source: file.source, role: file.role, baseDir: file.baseDir });
|
||||||
} else if (value !== null) {
|
} else if (value !== null) {
|
||||||
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,27 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge a definition's `role=`/filename metadata into a parsed object, erroring on conflict. */
|
||||||
|
function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const meta = file.role;
|
||||||
|
if (!meta) return value;
|
||||||
|
const out = { ...value };
|
||||||
|
const keys: Array<[key: string, fromMeta: string | undefined]> = [
|
||||||
|
['role', meta.role],
|
||||||
|
['type', meta.type],
|
||||||
|
['id', meta.id],
|
||||||
|
];
|
||||||
|
for (const [key, fromMeta] of keys) {
|
||||||
|
if (fromMeta === undefined) continue;
|
||||||
|
const fromContent = out[key];
|
||||||
|
if (fromContent !== undefined && fromContent !== fromMeta) {
|
||||||
|
throw new BgmError(`Conflicting ${key}: "${fromMeta}" in the name vs "${fromContent}" in content`, file.source);
|
||||||
|
}
|
||||||
|
out[key] = fromMeta;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseText(kind: DefFile['kind'], text: string): unknown {
|
function parseText(kind: DefFile['kind'], text: string): unknown {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'json':
|
case 'json':
|
||||||
@@ -92,6 +118,8 @@ export function readDefFiles(dir: string, root: string): DefFile[] {
|
|||||||
text: fs.readFileSync(abs, 'utf8'),
|
text: fs.readFileSync(abs, 'utf8'),
|
||||||
source: abs,
|
source: abs,
|
||||||
kind,
|
kind,
|
||||||
|
role: roleFromName(entry.name),
|
||||||
|
baseDir: root && relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,19 @@ const surfaceSchema = z.object({
|
|||||||
layout: z.array(route),
|
layout: z.array(route),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setupValue = z.union([z.string(), z.array(z.string())]);
|
||||||
|
|
||||||
|
const setupPlacement = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
parts: setupValue,
|
||||||
|
facing: z.enum(['face', 'back', 'standing']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const setupSchema = z.object({
|
const setupSchema = z.object({
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
surfaces: z.array(z.string()).optional(),
|
surfaces: z.array(z.string()).optional(),
|
||||||
setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
|
setup: z.array(setupPlacement),
|
||||||
});
|
});
|
||||||
|
|
||||||
const packageSchema = z.object({
|
const packageSchema = z.object({
|
||||||
|
|||||||
@@ -157,17 +157,71 @@ export interface Surface {
|
|||||||
|
|
||||||
export type SetupValue = string | string[];
|
export type SetupValue = string | string[];
|
||||||
|
|
||||||
/** Seeds the state store: the enabled surfaces and a map from path to parts. */
|
/**
|
||||||
|
* How a part is oriented on the board. `face` lays it flat front-up, `back`
|
||||||
|
* flips it over front-down, and `standing` stands it on its bottom edge.
|
||||||
|
*/
|
||||||
|
export type Facing = 'face' | 'back' | 'standing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One setup placement: move `parts` to `path`. Entries are applied in order,
|
||||||
|
* so a part listed in a later entry ends up on that entry's path.
|
||||||
|
*/
|
||||||
|
export interface SetupPlacement {
|
||||||
|
/** The path key to place the parts on. */
|
||||||
|
path: string;
|
||||||
|
/** Parts to place: a part id, a bare type (expands to all of that type), or a list of either. */
|
||||||
|
parts: SetupValue;
|
||||||
|
/** Initial facing for the placed parts; defaults to `face`. */
|
||||||
|
facing?: Facing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds the state store: the enabled surfaces and an ordered list of placements. */
|
||||||
export interface Setup {
|
export interface Setup {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
||||||
surfaces?: string[];
|
surfaces?: string[];
|
||||||
setup: Record<string, SetupValue>;
|
/** Ordered placements; each moves its parts to its path. */
|
||||||
|
setup: SetupPlacement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Role = 'package' | 'part' | 'surface' | 'setup';
|
export type Role = 'package' | 'part' | 'surface' | 'setup';
|
||||||
|
|
||||||
|
/** The four definition roles. */
|
||||||
|
export const ROLES: ReadonlySet<Role> = new Set(['package', 'part', 'surface', 'setup']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role metadata declared on a code block's info string (`role=part.cargo`) or
|
||||||
|
* a real file's name (`part.cargo.yaml`). `type` is required for all roles
|
||||||
|
* except `package`; `id` is optional — anything not given comes from the
|
||||||
|
* content or from `$variants` rows.
|
||||||
|
*/
|
||||||
|
export interface RoleMeta {
|
||||||
|
role: Role;
|
||||||
|
type?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical file name for a role, e.g. `part.cargo.yaml`. */
|
||||||
|
export function roleToName(role: RoleMeta, ext = 'yaml'): string {
|
||||||
|
if (role.role === 'package') return `package.${ext}`;
|
||||||
|
return `${role.role}.${role.type}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `role.type.lang` file name into role metadata, or `undefined` when
|
||||||
|
* the name is not a definition (`package.yaml`, `part.cargo.yaml`, ...).
|
||||||
|
*/
|
||||||
|
export function roleFromName(name: string): RoleMeta | undefined {
|
||||||
|
if (/^package\.(ya?ml|json|toml)$/i.test(name)) return { role: 'package' };
|
||||||
|
const m = /^(\w+)\.(.+)\.(ya?ml|json|toml)$/i.exec(name);
|
||||||
|
if (m && ROLES.has(m[1] as Role) && m[1] !== 'package') {
|
||||||
|
return { role: m[1] as Role, type: m[2] };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A raw definition object as written by the author. Definitions can be the
|
* A raw definition object as written by the author. Definitions can be the
|
||||||
* root of a file/block or an item in the file's list.
|
* root of a file/block or an item in the file's list.
|
||||||
@@ -208,6 +262,14 @@ export interface DefFile {
|
|||||||
source: string;
|
source: string;
|
||||||
/** File type derived from the name's extension. */
|
/** File type derived from the name's extension. */
|
||||||
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
||||||
|
/** Role declared on a code block's info string or a real file's name. */
|
||||||
|
role?: RoleMeta;
|
||||||
|
/**
|
||||||
|
* Directory (path-style, relative to the games root) that relative asset
|
||||||
|
* paths resolve against. For a code block, the markdown file's directory;
|
||||||
|
* for a real file, its own directory.
|
||||||
|
*/
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single parsed definition (one JSON object from a def file). */
|
/** A single parsed definition (one JSON object from a def file). */
|
||||||
@@ -219,6 +281,10 @@ export interface ParsedDef {
|
|||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||||
source: string;
|
source: string;
|
||||||
|
/** Role declared on the code block's info string or a real file's name. */
|
||||||
|
role?: RoleMeta;
|
||||||
|
/** Directory relative asset paths resolve against (see `DefFile.baseDir`). */
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('parseCsvData', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('expandVariants', () => {
|
describe('expandVariants', () => {
|
||||||
it('parses inline CSV when the value contains a newline', () => {
|
it('parses inline CSV when the first line does not end in .csv', () => {
|
||||||
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
||||||
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
||||||
});
|
});
|
||||||
@@ -61,15 +61,30 @@ describe('expandVariants', () => {
|
|||||||
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('concatenates rows from an array of csv paths', () => {
|
||||||
|
const defs = new Map<string, DefFile[]>([
|
||||||
|
['pkg/parts/a.csv', [defFile('pkg/parts/a.csv', 'id\nstring\nred')]],
|
||||||
|
['pkg/parts/b.csv', [defFile('pkg/parts/b.csv', 'id\nstring\nblack')]],
|
||||||
|
]);
|
||||||
|
const rows = expandVariants(['./a.csv', './b.csv'], 'pkg/parts/board.yaml', defs, 'src');
|
||||||
|
expect(rows).toEqual([{ id: 'red' }, { id: 'black' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when the referenced csv is missing', () => {
|
it('throws when the referenced csv is missing', () => {
|
||||||
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
/CSV not found/,
|
/CSV not found/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when $variants is not a string', () => {
|
it('throws when $variants is not a string or string array', () => {
|
||||||
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
/must be a path or inline CSV/,
|
/must be a path or inline CSV string, or an array of them/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an empty $variants array', () => {
|
||||||
|
expect(() => expandVariants([], 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
|
/must not be empty/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
* - Rows are validated against a schema derived from the type row.
|
* - Rows are validated against a schema derived from the type row.
|
||||||
* - A cell for an array/tuple type uses `;` as the element separator
|
* - A cell for an array/tuple type uses `;` as the element separator
|
||||||
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
||||||
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
|
* - `$variants` can be a single source or an array of them. A source is a
|
||||||
* containing a newline is inline CSV, otherwise it is a path.
|
* file/URL path if its first line ends in `.csv`, otherwise inline CSV.
|
||||||
*
|
*
|
||||||
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
||||||
* this header/schema/data layout and validates each row against a schema
|
* this header/schema/data layout and validates each row against a schema
|
||||||
@@ -74,7 +74,8 @@ export function parseCsvByName(
|
|||||||
/**
|
/**
|
||||||
* Expand a `$variants` value into rows.
|
* Expand a `$variants` value into rows.
|
||||||
*
|
*
|
||||||
* @param value the `$variants` value: a path or inline CSV
|
* @param value the `$variants` value: a path or inline CSV string, or an
|
||||||
|
* array of them
|
||||||
* @param baseName the path-style name of the referencing def file; a path
|
* @param baseName the path-style name of the referencing def file; a path
|
||||||
* value resolves relative to its directory
|
* value resolves relative to its directory
|
||||||
* @param defs the virtual def map, for resolving the path
|
* @param defs the virtual def map, for resolving the path
|
||||||
@@ -86,14 +87,41 @@ export function expandVariants(
|
|||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
source: string,
|
source: string,
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
if (typeof value !== 'string') {
|
const sources = Array.isArray(value) ? value : [value];
|
||||||
throw new BgmError('`$variants` must be a path or inline CSV string', source);
|
if (sources.length === 0) {
|
||||||
|
throw new BgmError('`$variants` array must not be empty', source);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.includes('\n')) {
|
const rows: Record<string, unknown>[] = [];
|
||||||
return parseCsvData(value, source).rows;
|
for (const item of sources) {
|
||||||
|
if (typeof item !== 'string') {
|
||||||
|
throw new BgmError(
|
||||||
|
'`$variants` must be a path or inline CSV string, or an array of them',
|
||||||
|
source,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
rows.push(...expandVariantsOne(item, baseName, defs, source));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a single `$variants` source: a path or inline CSV.
|
||||||
|
*
|
||||||
|
* A source is a path when its first line ends in `.csv`; otherwise it is
|
||||||
|
* inline CSV. This keeps the two forms self-documenting and applies the same
|
||||||
|
* rule to single values and array elements alike.
|
||||||
|
*/
|
||||||
|
function expandVariantsOne(
|
||||||
|
value: string,
|
||||||
|
baseName: string,
|
||||||
|
defs: Map<string, DefFile[]>,
|
||||||
|
source: string,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const firstLine = value.split('\n', 1)[0] ?? value;
|
||||||
|
if (/[.]csv$/i.test(firstLine)) {
|
||||||
const name = path.posix.join(path.posix.dirname(baseName), value);
|
const name = path.posix.join(path.posix.dirname(baseName), value);
|
||||||
return parseCsvByName(name, defs, source).rows;
|
return parseCsvByName(name, defs, source).rows;
|
||||||
|
}
|
||||||
|
return parseCsvData(value, source).rows;
|
||||||
}
|
}
|
||||||
@@ -36,5 +36,6 @@ role: setup
|
|||||||
type: game
|
type: game
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/deck: harbor:token
|
- path: /deck
|
||||||
|
parts: harbor:token
|
||||||
```
|
```
|
||||||
@@ -24,7 +24,7 @@ const tree = resolveMountTree(
|
|||||||
new Set(Object.keys(seeded.surfaces)),
|
new Set(Object.keys(seeded.surfaces)),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const seededPaths = Object.keys(seeded.paths);
|
export const seededPaths = Object.keys(seeded.parts);
|
||||||
export const expanded = expandSetupValue(pkg as never, 'harbor:token');
|
export const expanded = expandSetupValue(pkg as never, 'harbor:token');
|
||||||
export const placementCount = placements.length;
|
export const placementCount = placements.length;
|
||||||
export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0');
|
export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
fallbackShape,
|
fallbackShape,
|
||||||
traceToShape,
|
traceToShape,
|
||||||
traceToUvBounds,
|
traceToUvBounds,
|
||||||
|
facingTransform,
|
||||||
MM_TO_WORLD,
|
MM_TO_WORLD,
|
||||||
} from './part.js';
|
} from './part.js';
|
||||||
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||||
@@ -24,7 +25,7 @@ export {
|
|||||||
computeSurfacePlacements,
|
computeSurfacePlacements,
|
||||||
placementKey,
|
placementKey,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
export type { GameState, Placement } from './state.js';
|
export type { GameState, Placement, PartState } from './state.js';
|
||||||
|
|
||||||
// Setup seeding.
|
// Setup seeding.
|
||||||
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
fallbackShape,
|
fallbackShape,
|
||||||
traceToShape,
|
traceToShape,
|
||||||
traceToUvBounds,
|
traceToUvBounds,
|
||||||
|
facingTransform,
|
||||||
} from './part.js';
|
} from './part.js';
|
||||||
|
|
||||||
describe('partDimensions', () => {
|
describe('partDimensions', () => {
|
||||||
@@ -20,6 +21,25 @@ describe('partDimensions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('facingTransform', () => {
|
||||||
|
const dims = { height: 2, depth: 0.1 };
|
||||||
|
|
||||||
|
it('lays face-up flat on the minZ face', () => {
|
||||||
|
expect(facingTransform('face', dims)).toEqual({ pivot: [0, 0, 0], xRotation: -Math.PI / 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays back-down flat on the maxZ face', () => {
|
||||||
|
expect(facingTransform('back', dims)).toEqual({ pivot: [0, 0, 0.1], xRotation: Math.PI / 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands on the bottom (minY) edge', () => {
|
||||||
|
expect(facingTransform('standing', dims)).toEqual({
|
||||||
|
pivot: [0, -1, 0.05],
|
||||||
|
xRotation: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('spriteUvFromCrop', () => {
|
describe('spriteUvFromCrop', () => {
|
||||||
it('returns full-image UVs without a crop', () => {
|
it('returns full-image UVs without a crop', () => {
|
||||||
expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
|
expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* so they can be unit-tested in a plain node environment (mirroring the web
|
* so they can be unit-tested in a plain node environment (mirroring the web
|
||||||
* app's `cardResolution.ts`).
|
* app's `cardResolution.ts`).
|
||||||
*/
|
*/
|
||||||
import type { Part, Crop } from '@tts/bgm';
|
import type { Part, Crop, Facing } from '@tts/bgm';
|
||||||
import {
|
import {
|
||||||
rectShape,
|
rectShape,
|
||||||
roundedRectShape,
|
roundedRectShape,
|
||||||
@@ -31,6 +31,31 @@ export function partDimensions(part: Part): { width: number; height: number; dep
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transform that orients a part for a `facing`, in the part's mesh-local
|
||||||
|
* frame (shape in XY centered at origin, extruded along +Z from `0` to
|
||||||
|
* `depth`). `pivot` is the center of the face/edge that rests on the table and
|
||||||
|
* should land at the anchor; `xRotation` (radians, about the local X axis)
|
||||||
|
* orients the part. Tilt is applied separately about the local Y (long) axis,
|
||||||
|
* so both the facing rotation and tilt spin about the anchor.
|
||||||
|
*/
|
||||||
|
export function facingTransform(
|
||||||
|
facing: Facing,
|
||||||
|
dims: { height: number; depth: number },
|
||||||
|
): { pivot: [number, number, number]; xRotation: number } {
|
||||||
|
switch (facing) {
|
||||||
|
case 'face':
|
||||||
|
// Lay flat, front up, resting on the minZ face.
|
||||||
|
return { pivot: [0, 0, 0], xRotation: -Math.PI / 2 };
|
||||||
|
case 'back':
|
||||||
|
// Lay flat, front down, resting on the maxZ face.
|
||||||
|
return { pivot: [0, 0, dims.depth], xRotation: Math.PI / 2 };
|
||||||
|
case 'standing':
|
||||||
|
// Stand upright on the bottom (minY) edge.
|
||||||
|
return { pivot: [0, -dims.height / 2, dims.depth / 2], xRotation: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UV repeat/offset that selects a single sprite from a sheet, given a crop
|
* UV repeat/offset that selects a single sprite from a sheet, given a crop
|
||||||
* `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid
|
* `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import type { Package } from '@tts/bgm';
|
|||||||
import { useStacking } from './stacking.js';
|
import { useStacking } from './stacking.js';
|
||||||
import type { Placement } from './state.js';
|
import type { Placement } from './state.js';
|
||||||
import { PartView } from './partView.js';
|
import { PartView } from './partView.js';
|
||||||
import { MM_TO_WORLD, DEG_TO_RAD } from './part.js';
|
import { MM_TO_WORLD, DEG_TO_RAD, facingTransform, partDimensions } from './part.js';
|
||||||
|
|
||||||
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
|
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
|
||||||
const { route, candidate, piece, index, stackSize } = placement;
|
const { route, candidate, piece, index, stackSize, facing } = placement;
|
||||||
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
||||||
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
||||||
if (!part) return null;
|
if (!part) return null;
|
||||||
@@ -34,12 +34,19 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
|||||||
const anchorRotation =
|
const anchorRotation =
|
||||||
((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD;
|
((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD;
|
||||||
|
|
||||||
|
// The facing pivot is the center of the face/edge that rests on the table
|
||||||
|
// and should land at the anchor. Translate the mesh by the pivot, then apply
|
||||||
|
// the facing rotation and tilt about it, so the part sits on the table.
|
||||||
|
const { width, height, depth } = partDimensions(part);
|
||||||
|
const { pivot, xRotation } = facingTransform(facing, { height, depth });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||||
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
<group position={[-pivot[0], -pivot[1], -pivot[2]]}>
|
||||||
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
<group rotation={[xRotation, tilt * DEG_TO_RAD, 0]}>
|
||||||
<PartView part={part} baseUrl={part.baseUrl} />
|
<PartView part={part} baseUrl={part.baseUrl} />
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,16 +40,49 @@ describe('seedFromSetup', () => {
|
|||||||
type: 'game',
|
type: 'game',
|
||||||
id: 'main',
|
id: 'main',
|
||||||
surfaces: ['board#harbor'],
|
surfaces: ['board#harbor'],
|
||||||
setup: { '/deck': 'harbor:card#fleet' },
|
setup: [{ path: '/deck', parts: 'harbor:card#fleet' }],
|
||||||
};
|
};
|
||||||
const state = seedFromSetup(pkg, setup);
|
const state = seedFromSetup(pkg, setup);
|
||||||
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
||||||
expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] });
|
expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, facing: 'face' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables all surfaces when omitted', () => {
|
it('enables all surfaces when omitted', () => {
|
||||||
const setup = { type: 'game', id: 'main', setup: {} };
|
const setup = { type: 'game', id: 'main', setup: [] };
|
||||||
const state = seedFromSetup(pkg, 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', () => {
|
||||||
|
// `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',
|
||||||
|
setup: [
|
||||||
|
{ 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' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the facing from the placement, defaulting to face', () => {
|
||||||
|
const setup = {
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
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' });
|
||||||
|
// No `facing` on the placement defaults to `face`.
|
||||||
|
expect(state.parts['harbor:token#grain']).toEqual({ path: '/hand', index: 0, facing: 'face' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
* `type` (no id) expands to all parts of that type during initialization.
|
* `type` (no id) expands to all parts of that type during initialization.
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Package, Setup } from '@tts/bgm';
|
import type { Package, Setup, SetupValue } from '@tts/bgm';
|
||||||
import { useTabletopStore } from './state.js';
|
import { useTabletopStore, type PartState } from './state.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
||||||
@@ -15,7 +15,7 @@ import { useTabletopStore } from './state.js';
|
|||||||
*/
|
*/
|
||||||
export function expandSetupValue(
|
export function expandSetupValue(
|
||||||
pkg: Package,
|
pkg: Package,
|
||||||
value: string | string[],
|
value: SetupValue,
|
||||||
): string[] {
|
): string[] {
|
||||||
const values = Array.isArray(value) ? value : [value];
|
const values = Array.isArray(value) ? value : [value];
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -39,7 +39,7 @@ export function expandSetupValue(
|
|||||||
/** Seed the store from a setup. Returns the resulting game state. */
|
/** Seed the store from a setup. Returns the resulting game state. */
|
||||||
export function seedFromSetup(pkg: Package, setup: Setup): {
|
export function seedFromSetup(pkg: Package, setup: Setup): {
|
||||||
surfaces: Record<string, boolean>;
|
surfaces: Record<string, boolean>;
|
||||||
paths: Record<string, string[]>;
|
parts: Record<string, PartState>;
|
||||||
} {
|
} {
|
||||||
const surfaces: Record<string, boolean> = {};
|
const surfaces: Record<string, boolean> = {};
|
||||||
if (setup.surfaces) {
|
if (setup.surfaces) {
|
||||||
@@ -48,11 +48,26 @@ export function seedFromSetup(pkg: Package, setup: Setup): {
|
|||||||
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paths: Record<string, string[]> = {};
|
// Apply placements in order: each moves its parts to its path. A part listed
|
||||||
for (const [path, value] of Object.entries(setup.setup)) {
|
// in a later placement ends up on that placement's path. Then index each
|
||||||
paths[path] = expandSetupValue(pkg, value);
|
// path's parts contiguously, in placement order, so `index` is always a
|
||||||
|
// valid position within its path's stack.
|
||||||
|
const parts: Record<string, PartState> = {};
|
||||||
|
for (const placement of setup.setup) {
|
||||||
|
for (const id of expandSetupValue(pkg, placement.parts)) {
|
||||||
|
parts[id] = { path: placement.path, index: 0, facing: placement.facing ?? 'face' };
|
||||||
}
|
}
|
||||||
return { surfaces, paths };
|
}
|
||||||
|
const byPath: Record<string, string[]> = {};
|
||||||
|
for (const [id, ps] of Object.entries(parts)) {
|
||||||
|
(byPath[ps.path] ??= []).push(id);
|
||||||
|
}
|
||||||
|
for (const [path, ids] of Object.entries(byPath)) {
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
parts[id] = { ...parts[id]!, index };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { surfaces, parts };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { Package, Surface } from '@tts/bgm';
|
import type { Package, Surface } from '@tts/bgm';
|
||||||
import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
|
import {
|
||||||
|
matchRoute,
|
||||||
|
childrenByPath,
|
||||||
|
computeSurfacePlacements,
|
||||||
|
computeRenderState,
|
||||||
|
placementKey,
|
||||||
|
} from './state.js';
|
||||||
|
|
||||||
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
||||||
return {
|
return {
|
||||||
@@ -59,24 +65,39 @@ describe('matchRoute', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
expect(childrenByPath(parts)).toEqual({
|
||||||
|
'/deck': ['harbor:card#b', 'harbor:card#a'],
|
||||||
|
'/community/0': ['harbor:card#c'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('computeSurfacePlacements', () => {
|
describe('computeSurfacePlacements', () => {
|
||||||
it('places parts on a matching route with index and stackSize', () => {
|
it('places parts on a matching route with index, stackSize, and facing', () => {
|
||||||
const surface = makeSurface({
|
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, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['harbor:card#a', 'harbor:card#b'],
|
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
|
||||||
|
'harbor:card#b': { path: '/deck', index: 1, facing: 'back' },
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(2);
|
expect(placements).toHaveLength(2);
|
||||||
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 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 });
|
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, facing: 'back' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops parts with no matching route', () => {
|
it('drops parts with no matching route', () => {
|
||||||
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
||||||
const placements = computeSurfacePlacements(surface, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['harbor:card#a'],
|
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
|
||||||
'/elsewhere': ['harbor:card#b'],
|
'harbor:card#b': { path: '/elsewhere', index: 0, facing: 'face' },
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(1);
|
expect(placements).toHaveLength(1);
|
||||||
expect(placements[0]!.piece).toBe('harbor:card#a');
|
expect(placements[0]!.piece).toBe('harbor:card#a');
|
||||||
@@ -94,7 +115,9 @@ describe('computeSurfacePlacements', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
|
||||||
|
});
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,29 +133,21 @@ describe('computeSurfacePlacements', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
|
||||||
|
});
|
||||||
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('keeps the same piece on two paths as distinct placements', () => {
|
it('orders a path by index regardless of insertion order', () => {
|
||||||
const surface = makeSurface({
|
const surface = makeSurface({
|
||||||
layout: [
|
layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }],
|
||||||
{ 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, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['poker:card#as', 'poker:card#kh'],
|
'harbor:card#b': { path: '/deck', index: 1, facing: 'face' },
|
||||||
'/community/0': ['poker:card#as'],
|
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(3);
|
expect(placements.map((p) => p.piece)).toEqual(['harbor:card#a', 'harbor:card#b']);
|
||||||
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]!));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +155,7 @@ describe('computeRenderState', () => {
|
|||||||
it('only includes enabled surfaces', () => {
|
it('only includes enabled surfaces', () => {
|
||||||
const state = {
|
const state = {
|
||||||
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
||||||
paths: { '/deck': ['harbor:card#a'] },
|
parts: { 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' } },
|
||||||
};
|
};
|
||||||
pkg.surfaces.set(
|
pkg.surfaces.set(
|
||||||
'board#harbor',
|
'board#harbor',
|
||||||
@@ -153,14 +168,11 @@ describe('computeRenderState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('placementKey', () => {
|
describe('placementKey', () => {
|
||||||
it('is unique per surface, path, and piece', () => {
|
it('is unique per surface and piece', () => {
|
||||||
const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never;
|
const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never;
|
||||||
const b = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#b' } as never;
|
const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never;
|
||||||
const c = { surface: 'hud#hand', path: '/deck', piece: 'harbor:card#a' } as never;
|
const c = { surface: 'hud#hand', 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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* Source-of-truth game state and the derived render state.
|
* Source-of-truth game state and the derived render state.
|
||||||
*
|
*
|
||||||
* The store holds the enabled surfaces and the path -> part placement map
|
* The store holds the enabled surfaces and a per-part placement map
|
||||||
* (`bgm-tabletop.md` §2). The derived render state is computed from the game
|
* (`bgm-tabletop.md` §2). Each part on the board is keyed by its id
|
||||||
|
* (`package:type#id`) and records which path it's on, its index in that path's
|
||||||
|
* stack, and which face is up. A path's ordered children (for stacking) are
|
||||||
|
* derived from this map. The derived render state is computed from the game
|
||||||
* state plus a package's surface routes: a stable list of placements, one per
|
* state plus a package's surface routes: a stable list of placements, one per
|
||||||
* (surface, piece) pair, keyed for rendering.
|
* (surface, piece) pair, keyed for rendering.
|
||||||
*/
|
*/
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Candidate, Package, Route, Surface } from '@tts/bgm';
|
import type { Candidate, Facing, Package, Route, Surface } from '@tts/bgm';
|
||||||
|
|
||||||
|
/** The placement state of a single part on the board. */
|
||||||
|
export interface PartState {
|
||||||
|
/** The path key this part is on. */
|
||||||
|
path: string;
|
||||||
|
/** The part's position in its path's stack. */
|
||||||
|
index: number;
|
||||||
|
/** How the part is oriented on the board. */
|
||||||
|
facing: Facing;
|
||||||
|
}
|
||||||
|
|
||||||
/** Source-of-truth game state. */
|
/** Source-of-truth game state. */
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
/** Enabled per surface id (`type#id`). */
|
/** Enabled per surface id (`type#id`). */
|
||||||
surfaces: Record<string, boolean>;
|
surfaces: Record<string, boolean>;
|
||||||
/** Path -> part list (`package:type#id`). */
|
/** Part id (`package:type#id`) -> placement state. */
|
||||||
paths: Record<string, string[]>;
|
parts: Record<string, PartState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single placed piece on a surface, ready for rendering. */
|
/** A single placed piece on a surface, ready for rendering. */
|
||||||
@@ -34,26 +47,59 @@ export interface Placement {
|
|||||||
index: number;
|
index: number;
|
||||||
/** The number of pieces on the path. */
|
/** The number of pieces on the path. */
|
||||||
stackSize: number;
|
stackSize: number;
|
||||||
|
/** How the piece is oriented on the board. */
|
||||||
|
facing: Facing;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabletopState extends GameState {
|
interface TabletopState extends GameState {
|
||||||
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
||||||
setPaths: (paths: Record<string, string[]>) => void;
|
setParts: (parts: Record<string, PartState>) => void;
|
||||||
seed: (state: GameState) => void;
|
seed: (state: GameState) => void;
|
||||||
enableSurface: (id: string) => void;
|
enableSurface: (id: string) => void;
|
||||||
disableSurface: (id: string) => void;
|
disableSurface: (id: string) => void;
|
||||||
setPath: (path: string, parts: string[]) => void;
|
setPart: (id: string, patch: Partial<PartState>) => void;
|
||||||
|
movePart: (id: string, path: string, index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTabletopStore = create<TabletopState>((set) => ({
|
export const useTabletopStore = create<TabletopState>((set) => ({
|
||||||
surfaces: {},
|
surfaces: {},
|
||||||
paths: {},
|
parts: {},
|
||||||
setSurfaces: (surfaces) => set({ surfaces }),
|
setSurfaces: (surfaces) => set({ surfaces }),
|
||||||
setPaths: (paths) => set({ paths }),
|
setParts: (parts) => set({ parts }),
|
||||||
seed: (state) => set(state),
|
seed: (state) => set(state),
|
||||||
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
||||||
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
||||||
setPath: (path, parts) => set((s) => ({ paths: { ...s.paths, [path]: parts } })),
|
setPart: (id, patch) =>
|
||||||
|
set((s) => {
|
||||||
|
const cur = s.parts[id];
|
||||||
|
if (!cur) return s;
|
||||||
|
return { parts: { ...s.parts, [id]: { ...cur, ...patch } } };
|
||||||
|
}),
|
||||||
|
movePart: (id, path, index) =>
|
||||||
|
set((s) => {
|
||||||
|
const cur = s.parts[id];
|
||||||
|
if (!cur) return s;
|
||||||
|
const parts = { ...s.parts };
|
||||||
|
// Siblings on the source path, in order, excluding the moved part.
|
||||||
|
const srcIds = Object.keys(parts)
|
||||||
|
.filter((pid) => pid !== id && parts[pid]!.path === cur.path)
|
||||||
|
.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
||||||
|
// Siblings on the destination path, in order, excluding the moved part.
|
||||||
|
const dstIds = Object.keys(parts)
|
||||||
|
.filter((pid) => pid !== id && parts[pid]!.path === path)
|
||||||
|
.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
||||||
|
// Reindex the source path so the gap closes.
|
||||||
|
srcIds.forEach((pid, i) => {
|
||||||
|
parts[pid] = { ...parts[pid]!, index: i };
|
||||||
|
});
|
||||||
|
// Reindex the destination path with the moved part inserted at `index`.
|
||||||
|
const clamped = Math.max(0, Math.min(index, dstIds.length));
|
||||||
|
dstIds.forEach((pid, i) => {
|
||||||
|
parts[pid] = { ...parts[pid]!, index: i >= clamped ? i + 1 : i };
|
||||||
|
});
|
||||||
|
parts[id] = { ...cur, path, index: clamped };
|
||||||
|
return { parts };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- Route matching ---
|
// --- Route matching ---
|
||||||
@@ -87,24 +133,43 @@ export function matchRoute(route: Route, path: string): { candidate?: Candidate
|
|||||||
return { candidate: undefined };
|
return { candidate: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the placements for a single surface from the game state's paths. */
|
/**
|
||||||
export function computeSurfacePlacements(surface: Surface, paths: Record<string, string[]>): Placement[] {
|
* Derive each path's ordered children from the parts map. A path's children are
|
||||||
|
* its part ids sorted by `index`, used for stacking (`stackSize` and per-piece
|
||||||
|
* `index`).
|
||||||
|
*/
|
||||||
|
export function childrenByPath(parts: Record<string, PartState>): Record<string, string[]> {
|
||||||
|
const children: Record<string, string[]> = {};
|
||||||
|
for (const [id, ps] of Object.entries(parts)) {
|
||||||
|
(children[ps.path] ??= []).push(id);
|
||||||
|
}
|
||||||
|
for (const list of Object.values(children)) {
|
||||||
|
list.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the placements for a single surface from the game state's parts. */
|
||||||
|
export function computeSurfacePlacements(surface: Surface, parts: Record<string, PartState>): Placement[] {
|
||||||
const placements: Placement[] = [];
|
const placements: Placement[] = [];
|
||||||
const surfaceId = `${surface.type}#${surface.id}`;
|
const surfaceId = `${surface.type}#${surface.id}`;
|
||||||
for (const [path, parts] of Object.entries(paths)) {
|
const children = childrenByPath(parts);
|
||||||
|
for (const [path, ids] of Object.entries(children)) {
|
||||||
const route = surface.layout.find((r) => matchRoute(r, path));
|
const route = surface.layout.find((r) => matchRoute(r, path));
|
||||||
if (!route) continue;
|
if (!route) continue;
|
||||||
const match = matchRoute(route, path)!;
|
const match = matchRoute(route, path)!;
|
||||||
const stackSize = parts.length;
|
const stackSize = ids.length;
|
||||||
for (const piece of parts) {
|
for (const piece of ids) {
|
||||||
|
const ps = parts[piece]!;
|
||||||
placements.push({
|
placements.push({
|
||||||
surface: surfaceId,
|
surface: surfaceId,
|
||||||
path,
|
path,
|
||||||
route,
|
route,
|
||||||
candidate: match.candidate,
|
candidate: match.candidate,
|
||||||
piece,
|
piece,
|
||||||
index: parts.indexOf(piece),
|
index: ps.index,
|
||||||
stackSize,
|
stackSize,
|
||||||
|
facing: ps.facing,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,19 +183,23 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[]
|
|||||||
if (!enabled) continue;
|
if (!enabled) continue;
|
||||||
const surface = pkg.surfaces.get(surfaceId);
|
const surface = pkg.surfaces.get(surfaceId);
|
||||||
if (!surface) continue;
|
if (!surface) continue;
|
||||||
out.push(...computeSurfacePlacements(surface, state.paths));
|
out.push(...computeSurfacePlacements(surface, state.parts));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stable key for a placement, unique across surfaces, paths, and pieces. */
|
/**
|
||||||
|
* A stable key for a placement, unique across surfaces and pieces. A piece is
|
||||||
|
* on exactly one path, so `path` is implied; it may still render on more than
|
||||||
|
* one enabled surface, so the surface is part of the key.
|
||||||
|
*/
|
||||||
export function placementKey(p: Placement): string {
|
export function placementKey(p: Placement): string {
|
||||||
return `${p.surface}:${p.path}:${p.piece}`;
|
return `${p.surface}:${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. */
|
||||||
export function useRenderState(pkg: Package): Placement[] {
|
export function useRenderState(pkg: Package): Placement[] {
|
||||||
const surfaces = useTabletopStore((s) => s.surfaces);
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
const paths = useTabletopStore((s) => s.paths);
|
const parts = useTabletopStore((s) => s.parts);
|
||||||
return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]);
|
return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]);
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,9 @@ export function SurfaceBounds({ surface }: { surface: Surface }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{surface.size && <Line points={points} color="#22d3ee" lineWidth={1} />}
|
{surface.size && (
|
||||||
|
<Line points={points} color="#22d3ee" lineWidth={1} depthTest={false} />
|
||||||
|
)}
|
||||||
<Html position={[0, 0.05, 0]} center style={{ pointerEvents: 'none' }}>
|
<Html position={[0, 0.05, 0]} center style={{ pointerEvents: 'none' }}>
|
||||||
<div className="rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
<div className="rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
||||||
{surface.type}#{surface.id}
|
{surface.type}#{surface.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user