docs: add board game manifest specification
This commit is contained in:
@@ -0,0 +1,344 @@
|
|||||||
|
# Board Game Manifest — Technical Reference
|
||||||
|
|
||||||
|
> The concrete behavior of the board game manifest (bgm) format. This is the
|
||||||
|
> companion to the informal [`bgmanifest.md`](./bgmanifest.md) spec; where the
|
||||||
|
> two disagree, this document is authoritative.
|
||||||
|
>
|
||||||
|
> Definitions can live in JSON/YAML/TOML files or in markdown code blocks. In
|
||||||
|
> codeblock mode, each code block is a virtual definition file, named relative
|
||||||
|
> to the current markdown file.
|
||||||
|
>
|
||||||
|
> Assume every `.md` file in the package is preprocessed for virtual files:
|
||||||
|
> its code blocks are extracted and treated as definitions before any other
|
||||||
|
> resolution happens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. json features
|
||||||
|
|
||||||
|
### The `$variants` directive
|
||||||
|
|
||||||
|
For objects with a `$variants` key, the value is a CSV. Parse it into an object
|
||||||
|
array with `typed-csv`, extend the original object with each row, and return
|
||||||
|
the array.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
job: 'hero'
|
||||||
|
$variants: ./heroes.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name,parents
|
||||||
|
string,string[]
|
||||||
|
clark,[jonathan;martha]
|
||||||
|
bruce,[]
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
|
||||||
|
{ "job": "hero", "name": "bruce", "parents": [] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline vs file
|
||||||
|
|
||||||
|
`$variants` can be a file/URL path *or* an inline CSV string. If the value
|
||||||
|
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
|
||||||
|
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
$variants: |
|
||||||
|
id,name,faceCrop
|
||||||
|
string,string,[number;number;number;number]
|
||||||
|
fish,Fish,[0;0;5;2]
|
||||||
|
grain,Grain,[1;0;5;2]
|
||||||
|
wood,Wood,[2;0;5;2]
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSV conventions
|
||||||
|
|
||||||
|
CSV is parsed with `typed-csv`:
|
||||||
|
|
||||||
|
- The first row is the header, the second row is the type declaration
|
||||||
|
(`string`, `number`, `string[]`, ...), and the remaining rows are data.
|
||||||
|
- Rows are validated against a zod schema derived from the type row.
|
||||||
|
- **`crop` inside a CSV cell** uses `;` as the element separator
|
||||||
|
(`[0;0;5;2]`), because `,` is the CSV delimiter. `typed-csv` loads it into
|
||||||
|
an array with value `[0,0,5,2]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Definition discovery
|
||||||
|
|
||||||
|
Definitions are organized in **packages**. A loader loads a package
|
||||||
|
declaration, then uses its `include` paths to find the definitions.
|
||||||
|
|
||||||
|
### Code blocks as virtual files
|
||||||
|
|
||||||
|
A code block is a virtual definition file. To give it a name — so `include:`
|
||||||
|
and `$variants` paths can resolve against it — add a `file=` segment to the
|
||||||
|
code block's info string. The name is relative to the current markdown file:
|
||||||
|
|
||||||
|
````md
|
||||||
|
```yaml file=parts/cargo.yaml
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=parts/cargo.csv
|
||||||
|
...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- A block with `file=` is addressable by that path.
|
||||||
|
- A block without `file=` is auto-named `./${hash}.yaml`, where `hash`
|
||||||
|
is derived from its content. This makes every yaml block naturally
|
||||||
|
discoverable by the default `include: ./**/*.yaml`. Identical blocks dedupe
|
||||||
|
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.
|
||||||
|
- `file=` implies the file type from its extension; the language tag is
|
||||||
|
optional and only for editor highlighting.
|
||||||
|
- **Hash vs explicit `file=`:** a hashed name is for auto-discovery, not for
|
||||||
|
referencing. To point at a specific yaml block by name, give it an explicit
|
||||||
|
`file=`; otherwise its name is content-derived and unstable.
|
||||||
|
|
||||||
|
### include
|
||||||
|
|
||||||
|
`include` is a list of git-style path patterns — the defs that make up the
|
||||||
|
package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
|
||||||
|
folders is discovered with no configuration. This also matches the package
|
||||||
|
declaration itself, which is fine — it's the package, not a part.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Roles
|
||||||
|
|
||||||
|
json objects in yaml blocks are handled if they have a `role:` field for either
|
||||||
|
- `package`
|
||||||
|
- `part`
|
||||||
|
- `surface`
|
||||||
|
- `setup`
|
||||||
|
|
||||||
|
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.
|
||||||
|
`type#id` is used for identification so that combo must be unique in the package.
|
||||||
|
|
||||||
|
### package
|
||||||
|
|
||||||
|
The package is the container for a game's definitions. It is declared with a
|
||||||
|
`role: package` object:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: package
|
||||||
|
id: harbor
|
||||||
|
title: Harbor
|
||||||
|
designer: Jane Doe
|
||||||
|
players: 2
|
||||||
|
language: en
|
||||||
|
```
|
||||||
|
|
||||||
|
- `role`: for block discovery.
|
||||||
|
- `id`: package identification.
|
||||||
|
- `title` — game name.
|
||||||
|
- `include` — the defs that make up the package (see §2).
|
||||||
|
- Optional metadata: `designer`, `development` (artist/developer), `publisher`,
|
||||||
|
`players` (player count), `language`.
|
||||||
|
|
||||||
|
### part
|
||||||
|
|
||||||
|
A part is a game component. It is identified by a `package:type#id` string,
|
||||||
|
placed on the board via `setup`, and visualized by routes.
|
||||||
|
|
||||||
|
#### part value types
|
||||||
|
|
||||||
|
- `image` — a url to an image.
|
||||||
|
- `crop` — a tuple `[col, row, cols, rows]`. Divides the image into a grid
|
||||||
|
and picks the cell at `[col, row]` with size `[width/cols, height/rows]`.
|
||||||
|
Negative `cols` flips the rendered image.
|
||||||
|
- `size` — a tuple `[width, height, depth]` in mm units.
|
||||||
|
|
||||||
|
#### part props
|
||||||
|
|
||||||
|
- `face` — `sprite`. Used for texture.
|
||||||
|
- `faceCrop` — `crop` for `face`.
|
||||||
|
- `back` — `sprite`. Used for texture. Defaults to `face`.
|
||||||
|
- `backCrop` — `crop` for `back`.
|
||||||
|
- `shape` — `sprite`. Traced for its profile to create the mesh for the part.
|
||||||
|
Defaults to the full rect of the back image.
|
||||||
|
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
||||||
|
ratio is kept, but not z (thickness).
|
||||||
|
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
|
||||||
|
|
||||||
|
#### example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: part
|
||||||
|
type: token
|
||||||
|
id: wood
|
||||||
|
face: ./assets/tokens.png
|
||||||
|
faceCrop: [1, 0, 5, 2]
|
||||||
|
back: ./assets/tokens.png
|
||||||
|
backCrop: [3, 0, 5, 2]
|
||||||
|
shape: ./assets/token-shape.png
|
||||||
|
size: [20, 20, 3]
|
||||||
|
fillet: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
A `wood` token: the `face` and `back` sprites come from the same sheet,
|
||||||
|
`faceCrop`/`backCrop` picking different cells of the `5×2` grid. The shape is
|
||||||
|
traced from `token-shape.png`, sized `20×20×3` mm with a `2` mm fillet.
|
||||||
|
|
||||||
|
### surface
|
||||||
|
|
||||||
|
A `surface` is a **view** over the state store, purely for **visual rendering**.
|
||||||
|
It has a reference `size` (`[width, height]` in mm) and a `layout` list of
|
||||||
|
routes. The size is a reference — it may be scaled to fit larger or smaller
|
||||||
|
tables. It does not affect part placement; placement lives in the state store
|
||||||
|
(see §4). A surface need not cover every part — parts with no matching route on
|
||||||
|
this surface are simply not shown.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: board
|
||||||
|
id: harbor
|
||||||
|
role: surface
|
||||||
|
size: [300, 200]
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### setup
|
||||||
|
|
||||||
|
`setup` seeds the state store. Each valid game state is a valid setup.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: setup
|
||||||
|
type: game
|
||||||
|
id: main
|
||||||
|
setup:
|
||||||
|
/dock/0: harbor:boat#fleet
|
||||||
|
/deck: harbor:card
|
||||||
|
```
|
||||||
|
|
||||||
|
The value on a setup path can be either a string, or a string list.
|
||||||
|
|
||||||
|
The string can either be a one part string, or a type without an id.
|
||||||
|
|
||||||
|
When id is omitted, it expands to all parts in that type during game state initialization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Concepts
|
||||||
|
|
||||||
|
### Game state
|
||||||
|
|
||||||
|
The board's state is a **state store**: a map from path to a **stack** of
|
||||||
|
parts. It is the authoritative record of where every part is placed.
|
||||||
|
|
||||||
|
A path is a URL path with named params, like `/dock/1`.
|
||||||
|
|
||||||
|
A part is identified by a `package:type#id` string.
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
A route is a **visualization route**: it maps a part to a location on a
|
||||||
|
surface. Routes match the keys of the state store, but they are defined by a
|
||||||
|
surface and need not cover every placed part — a part with no matching route on
|
||||||
|
a given surface is simply not shown there. Routes exist only for game parts; a
|
||||||
|
surface is not a part and never appears on a route.
|
||||||
|
|
||||||
|
A route matches all parts on the path; the placement of each individual part on
|
||||||
|
the stack is a separate concern.
|
||||||
|
|
||||||
|
A route is an express-style URL path with named params, plus the `x`, `y`, and
|
||||||
|
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
|
||||||
|
same route path may appear more than once:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
x: 40
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Candidates
|
||||||
|
|
||||||
|
To match a class of routes against a list of positions, keep a single route with its param and give it a
|
||||||
|
`candidates` array to match `:param` against, each candidate carrying its own `x`/`y`/`rotation`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
seat,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,40,0,0
|
||||||
|
1,40,20,0
|
||||||
|
```
|
||||||
|
|
||||||
|
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
|
||||||
|
|
||||||
|
When no candidates match, the whole route fails to match.
|
||||||
|
|
||||||
|
### Stacking
|
||||||
|
|
||||||
|
When multiple parts live on a path, only the top (last) one shows by default.
|
||||||
|
To override this, add stacking strategies:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
stacking:
|
||||||
|
curve: M 0 0 C 20 -20 40 -20 60 0
|
||||||
|
limit: 5
|
||||||
|
align: center
|
||||||
|
steps: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
- `curve` — an SVG path string to spread the content along, relative to the
|
||||||
|
anchor `x`, `y`, `rotation`.
|
||||||
|
- `limit` — how many parts to display. `0` shows all, `3` shows the first 3,
|
||||||
|
`-3` shows the last 3.
|
||||||
|
- `align` — `start`, `end`, or `center` of the curve.
|
||||||
|
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
||||||
|
`1`. See the positioning process below.
|
||||||
|
|
||||||
|
#### positioning process
|
||||||
|
|
||||||
|
1. **Determine the step length.** It is `curve length / max(steps, # of
|
||||||
|
parts on path − 1)`.
|
||||||
|
2. **Determine the alignment.** It places the span of
|
||||||
|
`step length × (# of parts − 1)` on the curve.
|
||||||
|
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
||||||
|
each `step length` apart.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
- Object with no matching route → **not placed on this surface**. The game
|
||||||
|
state is still valid — the part simply isn't visualized. A surface is a view
|
||||||
|
over the state store, not a mirror of it, and may show only a subset (e.g. a
|
||||||
|
player's hand on the HUD).
|
||||||
|
- Route with no matching object → empty, fine.
|
||||||
|
- Multiple routes match one path -> first route wins.
|
||||||
|
- Multiple parts on one path → **stack** (see §4 Stacking). One route wins
|
||||||
|
for all parts on a path, and the stacking strategy decides what's shown
|
||||||
|
(it may drop parts that are not dropped on other matching routes).
|
||||||
Reference in New Issue
Block a user