Compare commits

..
Author SHA1 Message Date
Libby
0ba536eec1 research: swarm dashboard sources and first cut
Some checks failed
Portable Eve contracts and browser lowering / ubuntu-latest eve-browser-lowering (push) Has been cancelled
Portable Eve contracts and browser lowering / ubuntu-latest eve-contracts (push) Has been cancelled
Portable Eve contracts and browser lowering / windows-latest eve-browser-lowering (push) Has been cancelled
Portable Eve contracts and browser lowering / windows-latest eve-contracts (push) Has been cancelled
Thing 2 contract state, Run/heartbeat/usage-readout sources, and the
smallest first cut for "is anyone working, and on what".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 06:17:41 +00:00
Meta Vens
e119e76301 Merge readme-front-door: README front door
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:59:26 +02:00
Meta Vens
afe05b0c7e README front door: entry points and live/planned up front; drop absent relay command and config, repo-relative paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:55:21 +02:00
24 changed files with 243 additions and 2470 deletions

5
.gitignore vendored
View file

@ -8,11 +8,6 @@ packages/eve-browser-lowering/node_modules/
!packages/eve-contracts/**
packages/eve-contracts/node_modules/
packages/eve-contracts/*.tgz
!packages/thing-contract/
!packages/thing-contract/**
packages/thing-contract/node_modules/
packages/thing-contract/dist/
packages/thing-contract/dist-test/
!packages/org.gamecult.eve.surface/
!packages/org.gamecult.eve.surface/**
packages/org.gamecult.eve.surface/bin~/

View file

@ -1,9 +1,41 @@
# Thing
This repository is Thing. Its present tree is Thing 1, kept running for live
consumers until each ports; Thing 2.0 is built here (`docs/thing2-target.md`).
The Thing 1 renderer repos are archived under their Eve names: EveUnity,
EveFlutter, EveElectron, EveTui, EvePlugins and EveConformance.
This repository is Thing (the Eve display, control and sensor edge for GameCult
apps). Its present tree is Thing 1, kept running for live consumers until each
ports; Thing 2.0 is built here (`docs/thing2-target.md`). The Thing 1 renderer
repos are archived under their Eve names: EveUnity, EveFlutter, EveElectron,
EveTui, EvePlugins and EveConformance.
## Start Here
Value: services publish a surface once, as a typed retained document
(`gamecult.eve.surface.v1`), and every runtime lowers it locally. Providers keep
authority over state and commands; Eve owns projection, input capture and
timestamped sensor publication.
Problem: without a shared surface contract, each service hand-builds its own
dashboard and each renderer re-implements layout, binding and commands.
Live (Thing 1):
- `packages/org.gamecult.eve.surface`: renderer-neutral C# surface contract and
composition builders; tests in `tests/GameCult.Eve.Surface.Serialization.Tests`.
- `packages/eve-contracts`: TypeScript types and validators generated from
`schemas/` (`npm run generate`, `npm test`).
- `packages/eve-browser-lowering`: browser DOM/CSS lowering of surface documents
(`npm test`).
- `web/`: browser reference oracle (`web/index.html`, `web/eve-dsl.js`; tests
with `node --test web/eve-dsl.test.mjs`).
- `docs/surface-contract-v1.md`: the contract. `docs/repo-strategy.md`: repo
ownership. `docs/parity-testing-harness.md` with
`scripts/run-parity-harness.ps1`: parity runs.
- `android/` and `Sources/` (iOS Theos app, `make package`): native proof
clients; build steps under Build Shape below.
Planned (Thing 2.0, in progress): `crates/thing-contract` is the first Rust
piece. The layout solver, text, accessibility tree, closed render-command list
and thin backends described in `docs/thing2-target.md` and `docs/thing2-map.md`
are not built yet; `docs/thing-campaign.md` holds the rename plan.
CultMesh-based surface web, portable control layer, and timestamped sensor
edge.
@ -214,11 +246,8 @@ render:
## VoidBot CEF Stream
Start the Starfire relay from `E:\Projects\VoidBot`:
```powershell
npm run swarm:eve-cef-relay -- --width 1620 --height 2160 --scale 2 --port 8792
```
The CEF relay that produced these frames is not in VoidBot's default branch, so
no relay command is documented here until one exists.
EveCanvas displays binary JPEG frames and returns touch events only when a
stream lowering is configured through `EVE_STREAM_URLS`. The native app no
@ -226,12 +255,11 @@ longer guesses a Starfire relay address.
## Mimir Sensor Uplink
Start Mimir with `config/mimir-runtime.raven-eve.example.json` or run the
receiver processes directly:
Run the receiver processes from a Mimir checkout (a sibling `..\Mimir` here):
```powershell
dotnet run --project E:\Projects\Mimir\src\Mimir.EveSensorReceiver\Mimir.EveSensorReceiver.csproj -- --port 8793 --path /eve/camera --source-id eve-camera --type video-frame
dotnet run --project E:\Projects\Mimir\src\Mimir.EveSensorReceiver\Mimir.EveSensorReceiver.csproj -- --port 8794 --path /eve/mic --source-id eve-mic --type audio-block
dotnet run --project ..\Mimir\src\Mimir.EveSensorReceiver\Mimir.EveSensorReceiver.csproj -- --port 8793 --path /eve/camera --source-id eve-camera --type video-frame
dotnet run --project ..\Mimir\src\Mimir.EveSensorReceiver\Mimir.EveSensorReceiver.csproj -- --port 8794 --path /eve/mic --source-id eve-mic --type audio-block
```
EveCanvas sends binary `mimir.eve_media_observation.v1` camera and microphone
@ -245,7 +273,7 @@ guess receiver URLs.
Start the dashboard authority on Starfire:
```powershell
dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795
dotnet run --project ..\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795
```
EveCanvas connects to the Mimir dashboard WebSocket lowering for native retained

View file

@ -0,0 +1,201 @@
# Swarm dashboard: contract state, sources, first cut
Serves Eureka's ruling `swarm-dashboard-first-thing2-surface` (not readable from
the libby mind; cited by the id in the claiming follow-up
`libby-body:follow_up:chat-s354-359`). Read-only pass by the `libby` Self run
`libby:run:mind-20261031T061423908Z`, 2026-10-31. Nothing was built or deployed.
Claims below were probed on 2026-10-31 unless marked **unverified**.
The question the dashboard answers for a stranger: **is anyone working, and on what.**
## 1. Where Thing 2's surface contract stands
Probed against the forge `GameCult/Thing` (main `e119e76`, 2026-10-22) and its
`hands/thing2-*` branches. The source of truth for cut status is the `thing2`
campaign in the Eureka mind, which this pass cannot read; the prose map
(`docs/thing2-map.md`, last edited 2026-10-12) is the evidence used.
| Piece | State |
| --- | --- |
| Contract, Rust (`crates/thing-contract`) | On main. 13-arm closed `Node` union, `SurfaceDocument`, `CompositionDocument`, `CommandDecl/Invocation/Result`, `AssetCatalog`; byte goldens under `tests/goldens/`. |
| Contract, C# and TypeScript | Not on main. Branches `hands/thing2-contract-cs` (`3dadfc6`, 2026-10-14) and `hands/thing2-contract-ts` (`aeede04`, 2026-10-12). The map lists the cross-runtime store check and publication as waiting. |
| Core (resolution, `thing.std` compositions, binding via the CultMesh port, Taffy layout, command dispatch) | Not built. No crate exists. Decoding Verse surfaces waits on `cultlib-gaps:follow_up:decode-stack-safety`. |
| TUI backend | Not built. Planned first backend. |
| Web backend and shell | Not built. |
| Discovery/advertisement | Not a Thing kind (`thing2:ruling:advertisement-owner`); read through a core port; today's `gamecult.eve.provider_advertisement.v1` is unchanged. |
`README.md` says the same: the layout solver, text, accessibility tree, render
commands and backends "are not built yet". **Nothing can render a Thing 2
surface today.** The only live renderers are Thing 1's (browser oracle, Ghostlight's vendored lowering,
the Nightwing browser reference), and Eve dashboards are quarantined (`gamecult.md`: no new Eve surfaces).
### What a provider must publish to render
From `docs/thing2-map.md` "The consumer contract" and `crates/thing-contract/src`:
1. A `SurfaceDocument` (`gamecult.thing.surface.v1`, key `thing:surface:<providerId>.<label>`,
`version` bumped only on structure change). Closed nodes: `Box, Text, Image, Button, Toggle,
Slider, TextInput, Select, Repeat, Overlay, Plot, Embed, Use`. A swarm board is `Box` + `Text` +
`Repeat` (one row per instance) + `Use` of `thing.std` compositions (`card`, `metric`, `key-value`).
2. The **bound state records** the document points at, under the provider's own schema and key. A
`Bind` is `{provider?, schema, key, member path, format?}` plus `Item` inside a `Repeat`. A
collection binds to `{provider, schema, key_prefix}` or a list member of one record. No
expressions, no derived values: **the state's owner publishes derived values** (map rationale,
P5). So the dashboard's "working / idle" must be a published field, not computed in the surface.
3. Discovery: the provider is found through the existing advertisement, not a Thing record.
4. Commands only if the surface acts. A read-only board declares none.
A provider needs no layout or renderer code. It needs the contract package in its language
(Rust is the only one on main) and typed CultMesh state.
## 2. Sources (all typed state)
### 2a. Run documents, eight minds
Each mind is its own Huginn daemon (`HEARTBEAT_PERSONAS` in `/etc/gamecult/heartbeat/heartbeat.env`):
eureka 17872, libby 17873, heimdall 17874, kiko 17875, aqua 17876, weksa 17877, nibu 17878,
void 17879, all `rudp://10.77.0.1:<port>`. Probed: from this Self run,
`EUREKA_INSTANCE=<name> HUGINN_ENDPOINT=<endpoint> huginn query '{"schemas":["epiphany.pipeline.run.v2"],"limit":200}'`
answers on all eight, read-only, with caller `client unknown` (no per-client read identity yet).
Counts that day: eureka 68, libby 78, heimdall 94, kiko 70, aqua 81, weksa 74, nibu 71, void 87.
`epiphany.pipeline.run.v2` (`huginn schema run`): `instance, label, operated_by (Operator|Mind),
turn (PersonaTurn|SelfRun), host, started_on (a date), budget_usd, claims [PipelineRef], campaigns`.
What the dashboard can and cannot take from it:
- **Open/closed is not on the Run.** It is derived: a Run is closed when a `Resolution` with
`subject` = the run is in force (`huginn query` prints `InForce` or `Resolved:<outcome>`).
Resolution key `<instance>:resolution:run.<label>.n<seq>`. `huginn history <runId>` reads it.
- **Start time is a date.** The label `mind-YYYYMMDDTHHMMSSmmmZ` encodes the start to the
millisecond, but that is a convention in a string. The admission header (`huginn view`) carries
`admitted <timestamp>`, and the Resolution's header gives the end. Both are header facts, not
document fields. A dashboard needing start and end times wants them as typed fields.
- **"Outcome" is nearly empty.** Of 78 libby runs, 77 resolved `Recorded` and one is in force.
The meaning lives in free text: `reason "answered 1"` (55), `"answered 2"` (5), `"the connector
refused the credential lease: UnknownCaller"` (3), `"unit not active"`, `"failed after 0
answered..."`, plus rationales `The Persona unit's start ended.` (61) and `The Self run's carrier
closed the run its claude left open.` (11). Two Self-run resolutions carry the run's whole result
message inside `reason` (long prose, operator-facing detail). A public board
must **never project `reason` or `rationale`**.
- **"On what" is only on Self runs.** `claims` holds the follow-up or other document the run took;
a `PersonaTurn` has no claims (its work is the chat room). The claimed `follow_up.item` is the
operator's chat-relayed brief, free text, sometimes private. The typed handle (claim id, campaign
slug, the claimed document's `repo`) is safe; the `item` is not.
- **An open Run can be a dead Run.** Eleven libby Self runs were closed with "carrier closed the
run its claude left open", so for some interval an in-force Run belonged to nobody. "Open" is
"admitted and not yet resolved", not "a process is working".
- **Dollar fields:** `budget_usd` is on every Run. Do not bind it.
Snapshot at 06:16 UTC: two Self runs in force, `libby:run:mind-20261031T061423908Z` (this one) and
`void:run:mind-20261031T061543899Z` (claims `void-body:follow_up:chat-s335-340`); no Persona run
in force in any of the eight.
### 2b. Heartbeat state and poll trace
Source: `Huginn/crates/mind-body/src/heartbeat.rs`. `mind-heartbeat` is one Idunn-managed unit, sole
writer of `heartbeat.cc` (a single-file CultCache store under the unit's state directory). Two kinds:
- `eureka.heartbeat.initiative.v1`, key = instance slug: `initiative_usd, as_of, urgency, urgency_at,
last_burned_run, filled_events, classified_seq, launched_run, launch_spend_usd,
launch_spend_known, completions_through, launch_window`.
- `eureka.heartbeat.poll_trace.v1`, key = zero-padded unix ms, written only for polls that changed
something: `at, instances [InstanceTrace], launches [run label], burns [BurnTrace], classifier`.
`InstanceTrace`: `instance, initiative_usd, rate_usd_per_h, urgency, urgency_age_s, busy,
released, crossing_at, waited_s, idle_s, filled_by, fill_reason, note`.
Usable without dollars: `busy` (an in-force Persona run, from the mind), `released`, `crossing_at`,
`waited_s`, `idle_s`, `filled_by`/`fill_reason` (`tag` or `name`), `note`, `launches`, `urgency`,
`classifier`. These say **why nobody is working** (waiting for initiative, held by the envelope).
Dollar fields to leave alone: `initiative_usd`, `rate_usd_per_h`, `launch_spend_usd`,
`spend_usd`, and in the Initiative record the same names. The metering teardown removes them, and
`initiative_usd` is the unit initiative itself is denominated in, so the heartbeat's own model will
change under whatever binds this.
Gaps:
- **Not verified live.** `heartbeat.cc` is not readable from this Self run's account, and no
`/var/lib/gamecult` path for it was visible. The shape above is from source and
`eureka-body-map.md:534-540`, not from a read of the file.
- **No wire.** The heartbeat writes a file; it does not serve one. A reader opening a single-file
store while the heartbeat rewrites it has no stated safety (`FileStore::open` pulls the whole file
per call). A dashboard source must be published state, not a second process reading the file.
`mind-heartbeat timeline` prints the latest poll for a human.
- The trace is written only when a poll "changed something", so its newest line can be old while
the system is healthy. Staleness must come from the heartbeat's own published liveness, which
does not exist as a field.
### 2c. Connector usage readout
Source: `ModelConnector/schemas/usage_readout.json`, `crates/model-connector/src/readout.rs`.
`gamecult.model.usage_readout.v1`: `{schema_id, at_unix_ms, plan_quota: [{window, status,
utilization_permille (nullable), resets_at_unix_ms, observed_at_unix_ms}]}`. Whole-plan windows, no
per-caller field, **no dollars**: the safe source for "how much capacity is left".
Gaps: it is a connector operation (`usage_readout` on `gamecult.model.connector`), reached at the
heartbeat's `MIND_CONNECTOR_ENDPOINT` (`tcp://127.0.0.1:4110`), a loopback port with a per-caller
credential. Three libby Self runs were already refused with `UnknownCaller` on a lease, so a new
caller needs registration before it can ask. It is a request/answer, not a document in a store. A
dashboard needs a publisher that polls it and republishes it as a CultMesh record. `plan_quota`
also rides on every lease grant. **Unverified:** the live values, since asking requires a caller
credential this pass was not given.
### 2d. Not a source: the presence provider
CultLib's 10-29 presence provider attests Idunn-managed **service** liveness, not attention or work
(`libby-body:follow_up:presence-attention-not-carried`). It can answer "is the heartbeat unit up".
It cannot answer "is anyone working".
## 3. Smallest first cut
**Cut 1: one published read model, `swarm.work.v1`, from the Runs of the eight minds. No heartbeat,
no usage, no renderer.**
One record per instance (eight, key `<instance>`), derived and published by a single projector
(owner: the swarm-dashboard provider), holding only closed-set or id fields:
- `state`: `Working` (a Self run in force), `Turn` (a Persona run in force), or `Idle`.
- `run`: label of the newest in-force run, its `turn`, its start (from the admission header), else the
newest closed run's end.
- `on`: the claim id(s) and campaign slug(s), no free text.
- `observed_at` and the per-mind `as_of`, so a dead projector reads as stale.
This answers the stranger's question with typed state only. It deliberately omits `reason`,
`rationale`, `item`, `budget_usd`, and every `*_usd` field.
The Thing 2 surface for it is a `SurfaceDocument` that is a `Repeat` over those eight records, one
`thing.std.card` each, bound to `state` / `on` / the time fields. It can be authored and golden-tested
with the Rust contract on main. It cannot be seen until core (2a) and a backend exist. That is the
actual gating item, and it is not this cut's to work around with a Thing 1 or Eve surface.
Cut 2: add heartbeat liveness and the why-idle fields (`busy`, `released`, `waited_s`, `idle_s`,
`note`, `launches`), once the heartbeat publishes them as typed CultMesh state (it currently writes a
file only, and the dollar-denominated initiative model is under teardown).
Cut 3: add the `plan_quota` windows once a publisher polls the connector under a registered caller.
### Forks for the operator (recommendations given)
1. **Who is the stranger?** If public, the board shows ids and campaign slugs only (cut 1 as
written); "on what" in words needs a typed `public_label` that the claiming agent sets, not
`follow_up.item`. Recommend ids only until that field exists.
2. **Where does the projector live?** Recommend a small reader that queries the eight Huginns (all
answer today) and publishes `swarm.work.v1`, rather than adding publication to `mind-heartbeat`,
which is Idunn-managed with a single-file store and a dollar model in flux. Confidence moderate:
the cost is a new daemon, which needs the independent-lifecycle account `gamecult.md` requires.
3. **Is "open" enough?** Recommend marking `Working` only when a Run is in force **and** its
carrier is attested (the Run was admitted by a launch that is still live); the data for that
attestation is not in the Run. Until it is, say in the surface that `Working` means "admitted,
unresolved".
4. **Typed times.** Recommend asking Huginn for `started_at` and `ended_at` as Run/Resolution
document fields, so no consumer parses labels or header facts.
5. **Sequencing.** A stranger sees nothing until core and a backend land. If the swarm board should
come earlier than the TUI backend, say so; the TUI is the planned first backend and gives agents
a reader, but a stranger needs the web backend (map cut 4).
### Substrate gaps this pass felt
- The `swarm-dashboard-first-thing2-surface` ruling and the `thing2` campaign are in Eureka's mind,
not readable from libby's: cut status here comes from a 2026-10-12 prose map.
- `heartbeat.cc` unreadable from a Self run, and a file, not a served document.
- No typed `ended_at` / `started_at` on Run; outcome is free text in `reason`.
- No per-client read identity on the eight Huginns (`client unknown`).

View file

@ -1,71 +0,0 @@
{
"name": "@gamecult/thing-contract",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gamecult/thing-contract",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "file:../../../CultLib/packages/cultcache-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
},
"../../../CultLib/packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@gamecult/cultcache-ts": {
"resolved": "../../../CultLib/packages/cultcache-ts",
"link": true
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
}
}

View file

@ -1,29 +0,0 @@
{
"name": "@gamecult/thing-contract",
"version": "0.1.0",
"description": "The gamecult.thing.* wire types and codecs for TypeScript, held byte for byte to the Rust crate's goldens.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
},
"dependencies": {
"@gamecult/cultcache-ts": "file:../../../CultLib/packages/cultcache-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"license": "MIT"
}

View file

@ -1,511 +0,0 @@
// One codec per type of crates/thing-contract, built from cultcache-ts's slots, union, list, nullable and lazy at the
// crate's slot and arm keys. Nothing here encodes or decodes by hand, declares a slot default, or checks an arm: the
// library's codecs refuse an unknown arm or a missing slot, and domain.ts holds the five domains.
//
// Every slots and union codec is named GameCult.Thing.Contract.<RustName>, the reference's full type name, because a
// store's schema catalog carries the type name of each document slot and the reference compares them.
//
// Definition order is dependency order. The one cycle is Node (through Box, Button, Overlay, Repeat and Slot), and it
// closes at slot level with `lazy`, never inside a union's arm table.
import { codecs } from "@gamecult/cultcache-ts";
import type { Codec } from "@gamecult/cultcache-ts";
import { channel, count, segment, version } from "./domain.js";
import type * as T from "./types.js";
const { string, boolean, float64, bytes, list, nullable, lazy, unit } = codecs;
const named = (rustName: string): string => `GameCult.Thing.Contract.${rustName}`;
// A union codec reads nil as null. A required union slot is typed without the null; a nil there is CultLib's
// nullable-reference rule (the reference reads nil too), not this package's.
const required = <TValue>(codec: Codec<TValue | null>): Codec<TValue> => codec as Codec<TValue>;
// source.rs
export const paramName = codecs.slots<T.ParamName>({ 0: ["name", string] }, named("ParamName"));
export const textLiteral = codecs.slots<T.TextLiteral>({ 0: ["value", string] }, named("TextLiteral"));
export const numberLiteral = codecs.slots<T.NumberLiteral>({ 0: ["value", float64] }, named("NumberLiteral"));
export const boolLiteral = codecs.slots<T.BoolLiteral>({ 0: ["value", boolean] }, named("BoolLiteral"));
export const memberName = codecs.slots<T.MemberName>({ 0: ["name", string] }, named("MemberName"));
export const memberIndex = codecs.slots<T.MemberIndex>({ 0: ["index", count] }, named("MemberIndex"));
export const memberStep = required(
codecs.union<T.MemberStep>("MemberStep", { 0: ["Name", memberName], 1: ["Index", memberIndex] }, named("MemberStep")),
);
export const memberPath = codecs.slots<T.MemberPath>({ 0: ["steps", list(memberStep)] }, named("MemberPath"));
export const numberFormat = codecs.slots<T.NumberFormat>(
{ 0: ["precision", count], 1: ["percent", boolean], 2: ["unit", nullable(string)] },
named("NumberFormat"),
);
export const affixFormat = codecs.slots<T.AffixFormat>({ 0: ["prefix", string], 1: ["suffix", string] }, named("AffixFormat"));
export const format = required(
codecs.union<T.Format>(
"Format",
{ 0: ["Number", numberFormat], 1: ["Duration", unit], 2: ["Timestamp", unit], 3: ["Affix", affixFormat] },
named("Format"),
),
);
export const stateRef = codecs.slots<T.StateRef>(
{ 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["key", string] },
named("StateRef"),
);
export const stateSource = required(
codecs.union<T.StateSource>("StateSource", { 0: ["Record", stateRef], 1: ["Item", unit] }, named("StateSource")),
);
export const bind = codecs.slots<T.Bind>(
{ 0: ["source", stateSource], 1: ["member", memberPath], 2: ["format", nullable(format)] },
named("Bind"),
);
export const textSource = required(
codecs.union<T.TextSource>(
"TextSource",
{ 0: ["Literal", textLiteral], 1: ["Bound", bind], 2: ["Param", paramName] },
named("TextSource"),
),
);
export const numberSource = required(
codecs.union<T.NumberSource>(
"NumberSource",
{ 0: ["Literal", numberLiteral], 1: ["Bound", bind], 2: ["Param", paramName] },
named("NumberSource"),
),
);
export const boolSource = required(
codecs.union<T.BoolSource>(
"BoolSource",
{ 0: ["Literal", boolLiteral], 1: ["Bound", bind], 2: ["Param", paramName] },
named("BoolSource"),
),
);
export const recordsSource = codecs.slots<T.RecordsSource>(
{ 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["keyPrefix", string] },
named("RecordsSource"),
);
export const collectionSource = required(
codecs.union<T.CollectionSource>(
"CollectionSource",
{ 0: ["Records", recordsSource], 1: ["Member", bind], 2: ["Param", paramName] },
named("CollectionSource"),
),
);
// style.rs
export const scalar = codecs.slots<T.Scalar>({ 0: ["value", float64] }, named("Scalar"));
export const dimension = required(
codecs.union<T.Dimension>(
"Dimension",
{ 0: ["Px", scalar], 1: ["Percent", scalar], 2: ["Fr", scalar], 3: ["Auto", unit], 4: ["Content", unit] },
named("Dimension"),
),
);
export const size2 = codecs.slots<T.Size2>({ 0: ["width", dimension], 1: ["height", dimension] }, named("Size2"));
export const edges = codecs.slots<T.Edges>(
{ 0: ["top", dimension], 1: ["right", dimension], 2: ["bottom", dimension], 3: ["left", dimension] },
named("Edges"),
);
export const display = required(
codecs.union<T.Display>("Display", { 0: ["Flex", unit], 1: ["Grid", unit], 2: ["Block", unit] }, named("Display")),
);
export const direction = required(
codecs.union<T.Direction>(
"Direction",
{ 0: ["Row", unit], 1: ["Column", unit], 2: ["RowReverse", unit], 3: ["ColumnReverse", unit] },
named("Direction"),
),
);
export const wrap = required(
codecs.union<T.Wrap>("Wrap", { 0: ["NoWrap", unit], 1: ["Wrap", unit], 2: ["WrapReverse", unit] }, named("Wrap")),
);
export const position = required(
codecs.union<T.Position>("Position", { 0: ["Relative", unit], 1: ["Absolute", unit] }, named("Position")),
);
export const overflow = required(
codecs.union<T.Overflow>("Overflow", { 0: ["Visible", unit], 1: ["Clip", unit], 2: ["Scroll", unit] }, named("Overflow")),
);
export const itemAlign = required(
codecs.union<T.ItemAlign>(
"ItemAlign",
{
0: ["Auto", unit],
1: ["Start", unit],
2: ["End", unit],
3: ["Center", unit],
4: ["Stretch", unit],
5: ["Baseline", unit],
},
named("ItemAlign"),
),
);
export const contentAlign = required(
codecs.union<T.ContentAlign>(
"ContentAlign",
{
0: ["Auto", unit],
1: ["Start", unit],
2: ["End", unit],
3: ["Center", unit],
4: ["Stretch", unit],
5: ["SpaceBetween", unit],
6: ["SpaceAround", unit],
7: ["SpaceEvenly", unit],
},
named("ContentAlign"),
),
);
export const boxStyle = codecs.slots<T.BoxStyle>(
{
0: ["display", display],
1: ["direction", direction],
2: ["wrap", wrap],
3: ["gap", size2],
4: ["padding", edges],
5: ["margin", edges],
6: ["size", size2],
7: ["minSize", size2],
8: ["maxSize", size2],
9: ["position", position],
10: ["inset", edges],
11: ["overflow", overflow],
12: ["alignItems", itemAlign],
13: ["alignSelf", itemAlign],
14: ["alignContent", contentAlign],
15: ["justifyContent", contentAlign],
16: ["grow", float64],
17: ["shrink", float64],
18: ["basis", dimension],
19: ["gridColumns", list(dimension)],
20: ["gridRows", list(dimension)],
},
named("BoxStyle"),
);
export const themeToken = codecs.slots<T.ThemeToken>({ 0: ["name", string] }, named("ThemeToken"));
export const rgba = codecs.slots<T.Rgba>(
{ 0: ["r", channel], 1: ["g", channel], 2: ["b", channel], 3: ["a", channel] },
named("Rgba"),
);
export const colorRef = required(
codecs.union<T.ColorRef>("ColorRef", { 0: ["Token", themeToken], 1: ["Rgba", rgba] }, named("ColorRef")),
);
export const border = codecs.slots<T.Border>({ 0: ["color", colorRef], 1: ["width", float64] }, named("Border"));
export const paint = codecs.slots<T.Paint>(
{ 0: ["background", nullable(colorRef)], 1: ["border", nullable(border)], 2: ["radius", float64] },
named("Paint"),
);
export const role = required(
codecs.union<T.Role>(
"Role",
{
0: ["Group", unit],
1: ["Region", unit],
2: ["List", unit],
3: ["ListItem", unit],
4: ["Heading", unit],
5: ["Dialog", unit],
6: ["Toolbar", unit],
},
named("Role"),
),
);
// command.rs
export const commandDecl = codecs.slots<T.CommandDecl>(
{ 0: ["id", string], 1: ["payloadSchema", string], 2: ["captures", list(string)], 3: ["confirm", nullable(string)] },
named("CommandDecl"),
);
export const commandIdRef = codecs.slots<T.CommandIdRef>({ 0: ["id", string] }, named("CommandIdRef"));
export const commandRef = required(
codecs.union<T.CommandRef>("CommandRef", { 0: ["Id", commandIdRef], 1: ["Param", paramName] }, named("CommandRef")),
);
export const recordPayload = codecs.slots<T.RecordPayload>(
{ 0: ["schema", string], 1: ["bytes", bytes] },
named("RecordPayload"),
);
export const textValue = codecs.slots<T.TextValue>({ 0: ["value", string] }, named("TextValue"));
export const numberValue = codecs.slots<T.NumberValue>({ 0: ["value", float64] }, named("NumberValue"));
export const boolValue = codecs.slots<T.BoolValue>({ 0: ["value", boolean] }, named("BoolValue"));
export const choiceValue = codecs.slots<T.ChoiceValue>({ 0: ["value", string] }, named("ChoiceValue"));
export const inputValue = required(
codecs.union<T.InputValue>(
"InputValue",
{ 0: ["Text", textValue], 1: ["Number", numberValue], 2: ["Bool", boolValue], 3: ["Choice", choiceValue] },
named("InputValue"),
),
);
export const capturedInput = codecs.slots<T.CapturedInput>(
{ 0: ["node", string], 1: ["value", inputValue] },
named("CapturedInput"),
);
export const commandInvocation = codecs.slots<T.CommandInvocation>(
{
0: ["invocationId", string],
1: ["surfaceKey", string],
2: ["surfaceVersion", version],
3: ["command", string],
4: ["payload", recordPayload],
5: ["captured", list(capturedInput)],
6: ["client", string],
7: ["issuedAt", string],
8: ["delegation", nullable(string)],
},
named("CommandInvocation"),
);
// result.rs
export const pendingOutcome = codecs.slots<T.PendingOutcome>({ 0: ["message", string] }, named("PendingOutcome"));
export const openSurfaceEffect = codecs.slots<T.OpenSurfaceEffect>({ 0: ["key", string] }, named("OpenSurfaceEffect"));
export const openExternalEffect = codecs.slots<T.OpenExternalEffect>(
{ 0: ["url", string], 1: ["resume", commandRef] },
named("OpenExternalEffect"),
);
export const effect = required(
codecs.union<T.Effect>(
"Effect",
{ 0: ["OpenSurface", openSurfaceEffect], 1: ["OpenExternal", openExternalEffect] },
named("Effect"),
),
);
export const completedOutcome = codecs.slots<T.CompletedOutcome>(
{ 0: ["sourceVersion", nullable(version)], 1: ["clearDrafts", boolean], 2: ["effect", nullable(effect)] },
named("CompletedOutcome"),
);
export const deniedOutcome = codecs.slots<T.DeniedOutcome>(
{ 0: ["reason", string], 1: ["node", nullable(string)] },
named("DeniedOutcome"),
);
export const failedOutcome = codecs.slots<T.FailedOutcome>({ 0: ["reason", string] }, named("FailedOutcome"));
export const outcome = required(
codecs.union<T.Outcome>(
"Outcome",
{ 0: ["Pending", pendingOutcome], 1: ["Completed", completedOutcome], 2: ["Denied", deniedOutcome], 3: ["Failed", failedOutcome] },
named("Outcome"),
),
);
// node.rs
export const nodeCommon = codecs.slots<T.NodeCommon>(
{ 0: ["id", string], 1: ["visible", boolSource], 2: ["a11yLabel", nullable(textSource)] },
named("NodeCommon"),
);
// Node closes its own cycle here and nowhere else.
export const node: Codec<T.Node> = lazy(() => nodeUnion);
export const boxNode = codecs.slots<T.BoxNode>(
{ 0: ["common", nodeCommon], 1: ["style", boxStyle], 2: ["paint", paint], 3: ["role", role], 4: ["children", list(node)] },
named("BoxNode"),
);
export const textNode = codecs.slots<T.TextNode>(
{
0: ["common", nodeCommon],
1: ["content", textSource],
2: ["styleToken", string],
3: ["wrap", boolean],
4: ["maxLines", nullable(count)],
5: ["selectable", boolean],
},
named("TextNode"),
);
export const imageFit = required(
codecs.union<T.ImageFit>(
"ImageFit",
{ 0: ["Contain", unit], 1: ["Cover", unit], 2: ["Fill", unit], 3: ["Unscaled", unit] },
named("ImageFit"),
),
);
export const imageNode = codecs.slots<T.ImageNode>(
{ 0: ["common", nodeCommon], 1: ["catalog", string], 2: ["asset", string], 3: ["fit", imageFit], 4: ["alt", textSource] },
named("ImageNode"),
);
export const buttonNode = codecs.slots<T.ButtonNode>(
{ 0: ["common", nodeCommon], 1: ["content", list(node)], 2: ["onActivate", commandRef], 3: ["enabled", boolSource] },
named("ButtonNode"),
);
export const toggleNode = codecs.slots<T.ToggleNode>(
{ 0: ["common", nodeCommon], 1: ["checked", boolSource], 2: ["onChange", commandRef] },
named("ToggleNode"),
);
export const sliderNode = codecs.slots<T.SliderNode>(
{
0: ["common", nodeCommon],
1: ["min", float64],
2: ["max", float64],
3: ["step", float64],
4: ["value", numberSource],
5: ["commit", commandRef],
},
named("SliderNode"),
);
export const inputKind = required(
codecs.union<T.InputKind>("InputKind", { 0: ["Text", unit], 1: ["Number", unit] }, named("InputKind")),
);
export const textInputNode = codecs.slots<T.TextInputNode>(
{
0: ["common", nodeCommon],
1: ["multiline", boolean],
2: ["inputKind", inputKind],
3: ["placeholder", textSource],
4: ["initial", textSource],
5: ["submit", commandRef],
},
named("TextInputNode"),
);
export const selectOption = codecs.slots<T.SelectOption>({ 0: ["value", string], 1: ["label", string] }, named("SelectOption"));
export const literalOptions = codecs.slots<T.LiteralOptions>({ 0: ["options", list(selectOption)] }, named("LiteralOptions"));
export const boundOptions = codecs.slots<T.BoundOptions>(
{ 0: ["collection", collectionSource], 1: ["valueMember", memberPath], 2: ["labelMember", memberPath] },
named("BoundOptions"),
);
export const optionList = required(
codecs.union<T.OptionList>("OptionList", { 0: ["Literal", literalOptions], 1: ["Bound", boundOptions] }, named("OptionList")),
);
export const selectNode = codecs.slots<T.SelectNode>(
{ 0: ["common", nodeCommon], 1: ["options", optionList], 2: ["value", bind], 3: ["onChange", commandRef] },
named("SelectNode"),
);
export const repeatNode = codecs.slots<T.RepeatNode>(
{ 0: ["common", nodeCommon], 1: ["collection", collectionSource], 2: ["keyMember", memberPath], 3: ["template", node] },
named("RepeatNode"),
);
export const anchorNode = codecs.slots<T.AnchorNode>({ 0: ["node", string] }, named("AnchorNode"));
export const anchor = required(
codecs.union<T.Anchor>("Anchor", { 0: ["Viewport", unit], 1: ["Node", anchorNode] }, named("Anchor")),
);
export const overlayNode = codecs.slots<T.OverlayNode>(
{
0: ["common", nodeCommon],
1: ["anchor", anchor],
2: ["modal", boolean],
3: ["dismiss", commandRef],
4: ["children", list(node)],
},
named("OverlayNode"),
);
export const plotMark = required(
codecs.union<T.PlotMark>("PlotMark", { 0: ["Line", unit], 1: ["Bar", unit], 2: ["Area", unit] }, named("PlotMark")),
);
export const plotNode = codecs.slots<T.PlotNode>(
{
0: ["common", nodeCommon],
1: ["series", bind],
2: ["mark", plotMark],
3: ["xMin", nullable(float64)],
4: ["xMax", nullable(float64)],
5: ["yMin", nullable(float64)],
6: ["yMax", nullable(float64)],
},
named("PlotNode"),
);
export const embedNode = codecs.slots<T.EmbedNode>(
{
0: ["common", nodeCommon],
1: ["slotKind", string],
2: ["slotId", string],
3: ["width", float64],
4: ["height", float64],
},
named("EmbedNode"),
);
export const compositionRef = codecs.slots<T.CompositionRef>(
{ 0: ["publisher", string], 1: ["name", segment], 2: ["version", version] },
named("CompositionRef"),
);
export const textArg = codecs.slots<T.TextArg>({ 0: ["value", string] }, named("TextArg"));
export const numberArg = codecs.slots<T.NumberArg>({ 0: ["value", float64] }, named("NumberArg"));
export const boolArg = codecs.slots<T.BoolArg>({ 0: ["value", boolean] }, named("BoolArg"));
export const collectionArg = codecs.slots<T.CollectionArg>({ 0: ["source", collectionSource] }, named("CollectionArg"));
export const commandArg = codecs.slots<T.CommandArg>({ 0: ["command", commandRef] }, named("CommandArg"));
export const slotArg = codecs.slots<T.SlotArg>({ 0: ["nodes", list(node)] }, named("SlotArg"));
export const argValue = required(
codecs.union<T.ArgValue>(
"ArgValue",
{
0: ["Text", textArg],
1: ["Number", numberArg],
2: ["Bool", boolArg],
3: ["Bind", bind],
4: ["Collection", collectionArg],
5: ["Command", commandArg],
6: ["Slot", slotArg],
},
named("ArgValue"),
),
);
export const arg = codecs.slots<T.Arg>({ 0: ["name", string], 1: ["value", argValue] }, named("Arg"));
export const useNode = codecs.slots<T.UseNode>(
{ 0: ["common", nodeCommon], 1: ["composition", compositionRef], 2: ["args", list(arg)] },
named("UseNode"),
);
const nodeUnion = required(
codecs.union<T.Node>(
"Node",
{
0: ["Box", boxNode],
1: ["Text", textNode],
2: ["Image", imageNode],
3: ["Button", buttonNode],
4: ["Toggle", toggleNode],
5: ["Slider", sliderNode],
6: ["TextInput", textInputNode],
7: ["Select", selectNode],
8: ["Repeat", repeatNode],
9: ["Overlay", overlayNode],
10: ["Plot", plotNode],
11: ["Embed", embedNode],
12: ["Use", useNode],
},
named("Node"),
),
);
export const paramKind = required(
codecs.union<T.ParamKind>(
"ParamKind",
{
0: ["Text", unit],
1: ["Number", unit],
2: ["Bool", unit],
3: ["Bind", unit],
4: ["Collection", unit],
5: ["Command", unit],
6: ["Slot", unit],
},
named("ParamKind"),
),
);
export const paramDecl = codecs.slots<T.ParamDecl>({ 0: ["name", string], 1: ["kind", paramKind] }, named("ParamDecl"));
// The documents' slot records. CommandResult's is in result.ts: it is the one that mints an instance.
export const surfaceDocument = codecs.slots<T.SurfaceDocument>(
{
0: ["providerId", string],
1: ["label", segment],
2: ["version", version],
3: ["title", string],
4: ["root", node],
5: ["commands", list(commandDecl)],
},
named("SurfaceDocument"),
);
export const compositionDocument = codecs.slots<T.CompositionDocument>(
{
0: ["publisher", string],
1: ["name", segment],
2: ["version", version],
3: ["params", list(paramDecl)],
4: ["body", node],
},
named("CompositionDocument"),
);
export const assetEntry = codecs.slots<T.AssetEntry>(
{ 0: ["id", string], 1: ["mediaType", string], 2: ["uri", string], 3: ["width", count], 4: ["height", count] },
named("AssetEntry"),
);
export const assetCatalog = codecs.slots<T.AssetCatalog>(
{ 0: ["providerId", string], 1: ["catalogId", segment], 2: ["version", version], 3: ["assets", list(assetEntry)] },
named("AssetCatalog"),
);

View file

@ -1,65 +0,0 @@
// The five document definitions (crates/thing-contract/src/lib.rs). `type` and `schemaVersion` are the crate's
// cultcache(type, schema). The codec runs on both doors (encodeDocumentPayload and decodeDocumentPayload run it and then
// the definition's schema.parse), so the domains hold whichever way a value enters. The four plain kinds parse as the
// identity; a command_result parses only an instance of CommandResult.
import { defineDocumentRegistry, defineDocumentType } from "@gamecult/cultcache-ts";
import { assetCatalog, commandInvocation, compositionDocument as compositionCodec, surfaceDocument as surfaceCodec } from "./codecs.js";
import { CommandResult, commandResult as commandResultCodec } from "./result.js";
import type { AssetCatalog, CommandInvocation, CompositionDocument, SurfaceDocument } from "./types.js";
const identity = <TValue>(input: unknown): TValue => input as TValue;
export const surfaceDocument = defineDocumentType({
type: "gamecult.thing.surface",
schemaName: "gamecult.thing.surface",
schemaVersion: "gamecult.thing.surface.v1",
schema: { parse: identity<SurfaceDocument> },
codec: surfaceCodec,
});
export const compositionDocument = defineDocumentType({
type: "gamecult.thing.composition",
schemaName: "gamecult.thing.composition",
schemaVersion: "gamecult.thing.composition.v1",
schema: { parse: identity<CompositionDocument> },
codec: compositionCodec,
});
export const commandInvocationDocument = defineDocumentType({
type: "gamecult.thing.command_invocation",
schemaName: "gamecult.thing.command_invocation",
schemaVersion: "gamecult.thing.command_invocation.v1",
schema: { parse: identity<CommandInvocation> },
codec: commandInvocation,
});
export const commandResultDocument = defineDocumentType({
type: "gamecult.thing.command_result",
schemaName: "gamecult.thing.command_result",
schemaVersion: "gamecult.thing.command_result.v1",
schema: {
parse(input: unknown): CommandResult {
if (!CommandResult.isResult(input)) throw new Error("a CommandResult is built by its factories");
return input;
},
},
codec: commandResultCodec,
});
export const assetCatalogDocument = defineDocumentType({
type: "gamecult.thing.asset_catalog",
schemaName: "gamecult.thing.asset_catalog",
schemaVersion: "gamecult.thing.asset_catalog.v1",
schema: { parse: identity<AssetCatalog> },
codec: assetCatalog,
});
/** The five kinds, as the crate's ThingContract registers them. */
export const thingContract = defineDocumentRegistry(
surfaceDocument,
compositionDocument,
commandInvocationDocument,
commandResultDocument,
assetCatalogDocument,
);

View file

@ -1,51 +0,0 @@
// The value domains of the contract's numbers and local names (crates/thing-contract/src/domain.rs). Each is a leaf
// codec of cultcache-ts with the contract's bound added, so the bound holds on both doors of a document: encode and
// decode. A refusal names the domain and its bound, never the value (the leaf's own refusals do not echo one either).
import { codecs } from "@gamecult/cultcache-ts";
import type { Codec } from "@gamecult/cultcache-ts";
// A domain's wire form is its value, so its encode hands the checked value back: a caller that holds a number from
// outside (a factory argument, a key part) passes it through `encode` and keeps what comes out.
export interface Domain<TValue> extends Codec<TValue> {
encode(value: TValue): TValue;
}
function bounded<TValue extends number | bigint | string>(
leaf: Codec<TValue>,
refusal: (value: TValue) => string | null,
): Domain<TValue> {
const hold = (value: TValue): TValue => {
const refused = refusal(value);
if (refused !== null) throw new Error(refused);
return value;
};
return {
typeName: leaf.typeName,
valueType: leaf.valueType,
encode: (value) => hold(leaf.encode(value) as TValue),
decode: (raw) => hold(leaf.decode(raw)),
};
}
/** A result's position in its invocation's answers, counting from 1. */
export const seq = bounded(codecs.int32, (value) => (value >= 1 ? null : "Seq must be at least 1"));
/** A document or source version, never negative. */
export const version = bounded(codecs.int64, (value) => (value >= 0 ? null : "Version must be at least 0"));
/** A count, an extent or an index, never negative. */
export const count = bounded(codecs.int32, (value) => (value >= 0 ? null : "Count must be at least 0"));
/**
* A name the contract joins into a record key: non-empty, no '.'. A domain admits no nil, and the library refuses a nil
* in a value slot, so it is declared one.
*/
export const segment: Domain<string> = {
...bounded(codecs.string, (value) =>
value === "" ? "Segment must be non-empty" : value.includes(".") ? "Segment must be free of '.'" : null,
),
valueType: true,
};
/** One colour channel: a byte. */
export const channel = bounded(codecs.int32, (value) => (value >= 0 && value <= 255 ? null : "Rgba channel must be 0 to 255"));

View file

@ -1,6 +0,0 @@
export * as codecs from "./codecs.js";
export * as domain from "./domain.js";
export * from "./documents.js";
export * from "./keys.js";
export { CommandResult } from "./result.js";
export type * from "./types.js";

View file

@ -1,25 +0,0 @@
// The record key of each kind (crates/thing-contract/src/keys.rs). Provider, publisher and invocation ids are free
// strings (dotted, as the Verse names them); the trailing parts are typed, so a key splits at its last dot (surface,
// asset catalog, result) or last two (composition) and two different field sets never build one key. Each local
// segment goes through the segment codec, and a seq or version through its own.
import { segment, seq, version } from "./domain.js";
export function surfaceKey(providerId: string, label: string): string {
return `thing:surface:${providerId}.${segment.encode(label)}`;
}
export function compositionKey(publisher: string, name: string, versionNumber: number | bigint): string {
return `thing:composition:${publisher}.${segment.encode(name)}.v${version.encode(versionNumber)}`;
}
export function invocationKey(invocationId: string): string {
return `thing:invocation:${invocationId}`;
}
export function resultKey(invocationId: string, position: number): string {
return `thing:result:${invocationId}.${seq.encode(position)}`;
}
export function assetCatalogKey(providerId: string, catalogId: string): string {
return `thing:asset_catalog:${providerId}.${segment.encode(catalogId)}`;
}

View file

@ -1,86 +0,0 @@
// The answer to a command invocation. A CommandResult is built by its four factories, each of which copies the
// invocation's id, and by the command_result document codec when it decodes. Decoding is the other door and holds the
// same domain: `seq` is refused below 1 whichever door it enters by. No export takes a state as a string or converts a
// plain object: an object literal, a spread, structuredClone copy or parsed JSON is not a result, and encoding one is
// refused (schema.parse in documents.ts).
//
// The copied invocation id is a convenience and not provenance: core binds a result to an invocation it issued.
import { codecs } from "@gamecult/cultcache-ts";
import type { SlotsCodec } from "@gamecult/cultcache-ts";
import { outcome } from "./codecs.js";
import { seq } from "./domain.js";
import type { CommandInvocation, Effect, NodeId, Outcome } from "./types.js";
// The one constructor call. The class's static block sets `mint`; the factories and the codec below use it; it is not
// exported. The constructor refuses a call that does not carry `door`, so a script that reaches the constructor at run
// time (the TypeScript `private` is compile-time only) builds nothing, and it holds the seq domain for every door.
const door: unique symbol = Symbol("CommandResult.door");
let mint: (invocationId: string, position: number, outcome: Outcome) => CommandResult;
export class CommandResult {
readonly #brand = true;
readonly invocationId: string;
readonly seq: number;
readonly outcome: Outcome;
private constructor(key: typeof door, invocationId: string, position: number, outcome: Outcome) {
if (key !== door) throw new Error("a CommandResult is built by its factories");
this.invocationId = invocationId;
this.seq = seq.encode(position);
this.outcome = outcome;
Object.freeze(this);
}
static {
mint = (invocationId, position, outcome) => new CommandResult(door, invocationId, position, outcome);
}
static pending(invocation: CommandInvocation, position: number, message: string): CommandResult {
return mint(invocation.invocationId, position, { kind: "Pending", message });
}
static completed(
invocation: CommandInvocation,
position: number,
sourceVersion: number | bigint | null,
clearDrafts: boolean,
effect: Effect | null,
): CommandResult {
return mint(invocation.invocationId, position, { kind: "Completed", sourceVersion, clearDrafts, effect });
}
static denied(invocation: CommandInvocation, position: number, reason: string, node: NodeId | null): CommandResult {
return mint(invocation.invocationId, position, { kind: "Denied", reason, node });
}
static failed(invocation: CommandInvocation, position: number, reason: string): CommandResult {
return mint(invocation.invocationId, position, { kind: "Failed", reason });
}
/** True for an instance a factory or the codec made; false for every other value, whatever its shape or prototype. */
static isResult(value: unknown): value is CommandResult {
return typeof value === "object" && value !== null && #brand in value;
}
}
// The command_result document codec: the slot record, with decode minting an instance. Encode reads the instance's
// fields through the same slot record, so the seq domain is held on both doors.
const slotRecord = codecs.slots<{ invocationId: string; seq: number; outcome: Outcome }>(
{ 0: ["invocationId", codecs.string], 1: ["seq", seq], 2: ["outcome", outcome] },
"GameCult.Thing.Contract.CommandResult",
);
export const commandResult: SlotsCodec<CommandResult> = {
names: slotRecord.names,
typeName: slotRecord.typeName,
get members() {
return slotRecord.members;
},
encode: (value) => slotRecord.encode(value),
decode: (raw) => {
const { invocationId, seq: position, outcome: answer } = slotRecord.decode(raw);
return mint(invocationId, position, answer);
},
};

View file

@ -1,369 +0,0 @@
// One TypeScript type per type of crates/thing-contract. A record is an interface of readonly camelCase fields; a closed
// set is a union discriminated on `kind`, the Rust arm name, and an arm carries its payload's fields beside the tag (a
// unit arm is just the tag). Option<T> is `T | null`, Vec<T> is `readonly T[]`, bytes are a Uint8Array.
//
// The value domains (Seq, Version, Count, Segment, an Rgba channel) are held by the codecs in domain.ts, not by these
// types: a type cannot say "at least 1". Every document is minted and checked by its codec.
type Arm<Kind extends string, Payload = unknown> = { readonly kind: Kind } & Payload;
export type ProviderId = string;
export type SchemaId = string;
export type RecordKey = string;
export type NodeId = string;
export type CommandId = string;
// source.rs
export interface ParamName { readonly name: string }
export interface TextLiteral { readonly value: string }
export interface NumberLiteral { readonly value: number }
export interface BoolLiteral { readonly value: boolean }
export type TextSource = Arm<"Literal", TextLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>;
export type NumberSource = Arm<"Literal", NumberLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>;
export type BoolSource = Arm<"Literal", BoolLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>;
export interface Bind {
readonly source: StateSource;
readonly member: MemberPath;
readonly format: Format | null;
}
export type StateSource = Arm<"Record", StateRef> | Arm<"Item">;
export interface StateRef {
readonly provider: ProviderId | null;
readonly schema: SchemaId;
readonly key: RecordKey;
}
export interface MemberPath { readonly steps: readonly MemberStep[] }
export type MemberStep = Arm<"Name", MemberName> | Arm<"Index", MemberIndex>;
export interface MemberName { readonly name: string }
/** `index` is a Count: at least 0. */
export interface MemberIndex { readonly index: number }
export type Format = Arm<"Number", NumberFormat> | Arm<"Duration"> | Arm<"Timestamp"> | Arm<"Affix", AffixFormat>;
/** `precision` is a Count: at least 0. */
export interface NumberFormat {
readonly precision: number;
readonly percent: boolean;
readonly unit: string | null;
}
export interface AffixFormat { readonly prefix: string; readonly suffix: string }
export type CollectionSource = Arm<"Records", RecordsSource> | Arm<"Member", Bind> | Arm<"Param", ParamName>;
export interface RecordsSource {
readonly provider: ProviderId | null;
readonly schema: SchemaId;
readonly keyPrefix: string;
}
// style.rs
export interface Scalar { readonly value: number }
export type Dimension =
| Arm<"Px", Scalar>
| Arm<"Percent", Scalar>
| Arm<"Fr", Scalar>
| Arm<"Auto">
| Arm<"Content">;
export interface Size2 { readonly width: Dimension; readonly height: Dimension }
export interface Edges {
readonly top: Dimension;
readonly right: Dimension;
readonly bottom: Dimension;
readonly left: Dimension;
}
export type Display = Arm<"Flex"> | Arm<"Grid"> | Arm<"Block">;
export type Direction = Arm<"Row"> | Arm<"Column"> | Arm<"RowReverse"> | Arm<"ColumnReverse">;
export type Wrap = Arm<"NoWrap"> | Arm<"Wrap"> | Arm<"WrapReverse">;
export type Position = Arm<"Relative"> | Arm<"Absolute">;
export type Overflow = Arm<"Visible"> | Arm<"Clip"> | Arm<"Scroll">;
export type ItemAlign = Arm<"Auto"> | Arm<"Start"> | Arm<"End"> | Arm<"Center"> | Arm<"Stretch"> | Arm<"Baseline">;
export type ContentAlign =
| Arm<"Auto">
| Arm<"Start">
| Arm<"End">
| Arm<"Center">
| Arm<"Stretch">
| Arm<"SpaceBetween">
| Arm<"SpaceAround">
| Arm<"SpaceEvenly">;
export interface BoxStyle {
readonly display: Display;
readonly direction: Direction;
readonly wrap: Wrap;
readonly gap: Size2;
readonly padding: Edges;
readonly margin: Edges;
readonly size: Size2;
readonly minSize: Size2;
readonly maxSize: Size2;
readonly position: Position;
readonly inset: Edges;
readonly overflow: Overflow;
readonly alignItems: ItemAlign;
readonly alignSelf: ItemAlign;
readonly alignContent: ContentAlign;
readonly justifyContent: ContentAlign;
readonly grow: number;
readonly shrink: number;
readonly basis: Dimension;
readonly gridColumns: readonly Dimension[];
readonly gridRows: readonly Dimension[];
}
export interface ThemeToken { readonly name: string }
/** Each channel is a byte: 0 to 255. */
export interface Rgba { readonly r: number; readonly g: number; readonly b: number; readonly a: number }
export type ColorRef = Arm<"Token", ThemeToken> | Arm<"Rgba", Rgba>;
export interface Border { readonly color: ColorRef; readonly width: number }
export interface Paint {
readonly background: ColorRef | null;
readonly border: Border | null;
readonly radius: number;
}
export type Role =
| Arm<"Group">
| Arm<"Region">
| Arm<"List">
| Arm<"ListItem">
| Arm<"Heading">
| Arm<"Dialog">
| Arm<"Toolbar">;
// command.rs
export interface CommandDecl {
readonly id: CommandId;
readonly payloadSchema: SchemaId;
readonly captures: readonly NodeId[];
readonly confirm: string | null;
}
export interface CommandIdRef { readonly id: CommandId }
export type CommandRef = Arm<"Id", CommandIdRef> | Arm<"Param", ParamName>;
export interface RecordPayload { readonly schema: SchemaId; readonly bytes: Uint8Array }
export interface TextValue { readonly value: string }
export interface NumberValue { readonly value: number }
export interface BoolValue { readonly value: boolean }
export interface ChoiceValue { readonly value: string }
export type InputValue = Arm<"Text", TextValue> | Arm<"Number", NumberValue> | Arm<"Bool", BoolValue> | Arm<"Choice", ChoiceValue>;
export interface CapturedInput { readonly node: NodeId; readonly value: InputValue }
/**
* A consumer's request that a provider run a declared command. Its fields are plain, so a result's copy of
* `invocationId` is a convenience and not provenance: core binds results to the invocations it issued.
* `surfaceVersion` is a Version: at least 0.
*/
export interface CommandInvocation {
readonly invocationId: string;
readonly surfaceKey: string;
readonly surfaceVersion: number | bigint;
readonly command: CommandId;
readonly payload: RecordPayload;
readonly captured: readonly CapturedInput[];
readonly client: string;
readonly issuedAt: string;
readonly delegation: string | null;
}
// result.rs
export interface PendingOutcome { readonly message: string }
/** `sourceVersion` is a Version: at least 0. */
export interface CompletedOutcome {
readonly sourceVersion: number | bigint | null;
readonly clearDrafts: boolean;
readonly effect: Effect | null;
}
export interface DeniedOutcome { readonly reason: string; readonly node: NodeId | null }
export interface FailedOutcome { readonly reason: string }
export type Outcome =
| Arm<"Pending", PendingOutcome>
| Arm<"Completed", CompletedOutcome>
| Arm<"Denied", DeniedOutcome>
| Arm<"Failed", FailedOutcome>;
export interface OpenSurfaceEffect { readonly key: string }
export interface OpenExternalEffect { readonly url: string; readonly resume: CommandRef }
export type Effect = Arm<"OpenSurface", OpenSurfaceEffect> | Arm<"OpenExternal", OpenExternalEffect>;
/** A provider's handler for one invocation: the outcome it answers with. */
export type Handler = (invocation: CommandInvocation) => Outcome | Promise<Outcome>;
// node.rs
export interface NodeCommon {
readonly id: NodeId;
readonly visible: BoolSource;
readonly a11yLabel: TextSource | null;
}
export type Node =
| Arm<"Box", BoxNode>
| Arm<"Text", TextNode>
| Arm<"Image", ImageNode>
| Arm<"Button", ButtonNode>
| Arm<"Toggle", ToggleNode>
| Arm<"Slider", SliderNode>
| Arm<"TextInput", TextInputNode>
| Arm<"Select", SelectNode>
| Arm<"Repeat", RepeatNode>
| Arm<"Overlay", OverlayNode>
| Arm<"Plot", PlotNode>
| Arm<"Embed", EmbedNode>
| Arm<"Use", UseNode>;
export interface BoxNode {
readonly common: NodeCommon;
readonly style: BoxStyle;
readonly paint: Paint;
readonly role: Role;
readonly children: readonly Node[];
}
/** `maxLines` is a Count: at least 0. */
export interface TextNode {
readonly common: NodeCommon;
readonly content: TextSource;
readonly styleToken: string;
readonly wrap: boolean;
readonly maxLines: number | null;
readonly selectable: boolean;
}
export type ImageFit = Arm<"Contain"> | Arm<"Cover"> | Arm<"Fill"> | Arm<"Unscaled">;
export interface ImageNode {
readonly common: NodeCommon;
readonly catalog: string;
readonly asset: string;
readonly fit: ImageFit;
readonly alt: TextSource;
}
export interface ButtonNode {
readonly common: NodeCommon;
readonly content: readonly Node[];
readonly onActivate: CommandRef;
readonly enabled: BoolSource;
}
export interface ToggleNode {
readonly common: NodeCommon;
readonly checked: BoolSource;
readonly onChange: CommandRef;
}
export interface SliderNode {
readonly common: NodeCommon;
readonly min: number;
readonly max: number;
readonly step: number;
readonly value: NumberSource;
readonly commit: CommandRef;
}
export type InputKind = Arm<"Text"> | Arm<"Number">;
/**
* The Rust field is `kind`; here it is `inputKind`, because a TextInput arm carries its payload beside the union's own
* `kind` tag and the two cannot share a name.
*/
export interface TextInputNode {
readonly common: NodeCommon;
readonly multiline: boolean;
readonly inputKind: InputKind;
readonly placeholder: TextSource;
readonly initial: TextSource;
readonly submit: CommandRef;
}
export interface SelectOption { readonly value: string; readonly label: string }
export interface LiteralOptions { readonly options: readonly SelectOption[] }
export interface BoundOptions {
readonly collection: CollectionSource;
readonly valueMember: MemberPath;
readonly labelMember: MemberPath;
}
export type OptionList = Arm<"Literal", LiteralOptions> | Arm<"Bound", BoundOptions>;
export interface SelectNode {
readonly common: NodeCommon;
readonly options: OptionList;
readonly value: Bind;
readonly onChange: CommandRef;
}
export interface RepeatNode {
readonly common: NodeCommon;
readonly collection: CollectionSource;
readonly keyMember: MemberPath;
readonly template: Node;
}
export interface AnchorNode { readonly node: NodeId }
export type Anchor = Arm<"Viewport"> | Arm<"Node", AnchorNode>;
export interface OverlayNode {
readonly common: NodeCommon;
readonly anchor: Anchor;
readonly modal: boolean;
readonly dismiss: CommandRef;
readonly children: readonly Node[];
}
export type PlotMark = Arm<"Line"> | Arm<"Bar"> | Arm<"Area">;
export interface PlotNode {
readonly common: NodeCommon;
readonly series: Bind;
readonly mark: PlotMark;
readonly xMin: number | null;
readonly xMax: number | null;
readonly yMin: number | null;
readonly yMax: number | null;
}
export interface EmbedNode {
readonly common: NodeCommon;
readonly slotKind: string;
readonly slotId: string;
readonly width: number;
readonly height: number;
}
/** `name` is a Segment (non-empty, no '.'); `version` is a Version (at least 0). */
export interface CompositionRef {
readonly publisher: string;
readonly name: string;
readonly version: number | bigint;
}
export interface UseNode {
readonly common: NodeCommon;
readonly composition: CompositionRef;
readonly args: readonly Arg[];
}
export interface Arg { readonly name: string; readonly value: ArgValue }
export interface TextArg { readonly value: string }
export interface NumberArg { readonly value: number }
export interface BoolArg { readonly value: boolean }
export interface CollectionArg { readonly source: CollectionSource }
export interface CommandArg { readonly command: CommandRef }
export interface SlotArg { readonly nodes: readonly Node[] }
export type ArgValue =
| Arm<"Text", TextArg>
| Arm<"Number", NumberArg>
| Arm<"Bool", BoolArg>
| Arm<"Bind", Bind>
| Arm<"Collection", CollectionArg>
| Arm<"Command", CommandArg>
| Arm<"Slot", SlotArg>;
export type ParamKind =
| Arm<"Text">
| Arm<"Number">
| Arm<"Bool">
| Arm<"Bind">
| Arm<"Collection">
| Arm<"Command">
| Arm<"Slot">;
export interface ParamDecl { readonly name: string; readonly kind: ParamKind }
// The documents. `label`, `name` and `catalogId` are Segments, `version` a Version, an extent a Count. The fifth
// document, CommandResult, is a class (result.ts).
export interface SurfaceDocument {
readonly providerId: ProviderId;
readonly label: string;
readonly version: number | bigint;
readonly title: string;
readonly root: Node;
readonly commands: readonly CommandDecl[];
}
export interface CompositionDocument {
readonly publisher: string;
readonly name: string;
readonly version: number | bigint;
readonly params: readonly ParamDecl[];
readonly body: Node;
}
export interface AssetEntry {
readonly id: string;
readonly mediaType: string;
readonly uri: string;
readonly width: number;
readonly height: number;
}
export interface AssetCatalog {
readonly providerId: ProviderId;
readonly catalogId: string;
readonly version: number | bigint;
readonly assets: readonly AssetEntry[];
}

View file

@ -1,478 +0,0 @@
// The values the goldens are built from, ported from crates/thing-contract/tests/common/mod.rs and goldens.rs. Every
// value is an object literal of the package's exported types; no bytes are spelled here. The golden files are read, never
// written: the Rust crate blesses them.
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { CommandResult } from "@gamecult/thing-contract";
import type {
Arg,
AssetCatalog,
BoxNode,
Bind,
BoolSource,
CollectionSource,
CommandInvocation,
CommandRef,
CompositionDocument,
Dimension,
Edges,
Format,
MemberPath,
Node,
NodeCommon,
NumberSource,
Paint,
Size2,
SurfaceDocument,
TextSource,
BoxStyle,
} from "@gamecult/thing-contract";
export const CANARY = "CANARY-7f3a91c2";
export const PINNED_CLOCK = "2026-01-01T00:00:00Z";
/** The goldens directory, found by walking up from this file (a mutation sandbox sits deeper in the tree). */
export const goldensDir: string = (() => {
let here = dirname(fileURLToPath(import.meta.url));
for (;;) {
const candidate = join(here, "crates", "thing-contract", "tests", "goldens");
if (existsSync(candidate)) return candidate;
const parent = dirname(here);
if (parent === here) throw new Error("the Rust crate's goldens directory is not above this file");
here = parent;
}
})();
export function golden(file: string): Uint8Array {
return new Uint8Array(readFileSync(join(goldensDir, file)));
}
export const lit = (value: string): TextSource => ({ kind: "Literal", value });
export const litBool = (value: boolean): BoolSource => ({ kind: "Literal", value });
export const litNumber = (value: number): NumberSource => ({ kind: "Literal", value });
export const param = (name: string) => ({ kind: "Param", name }) as const;
export const path = (...names: string[]): MemberPath => ({ steps: names.map((name) => ({ kind: "Name", name }) as const) });
export const command = (id: string): CommandRef => ({ kind: "Id", id });
export function recordBind(member: MemberPath, format: Format | null): Bind {
return {
source: { kind: "Record", provider: "provider.a", schema: "gamecult.thing.test.v1", key: "row-1" },
member,
format,
};
}
export const itemBind = (member: MemberPath): Bind => ({ source: { kind: "Item" }, member, format: null });
export const common = (id: string): NodeCommon => ({ id, visible: litBool(true), a11yLabel: null });
export const px = (value: number): Dimension => ({ kind: "Px", value });
export const size2 = (width: Dimension, height: Dimension): Size2 => ({ width, height });
export const edges = (all: Dimension): Edges => ({ top: all, right: all, bottom: all, left: all });
export function style(): BoxStyle {
return {
display: { kind: "Flex" },
direction: { kind: "Column" },
wrap: { kind: "NoWrap" },
gap: size2(px(4), px(8)),
padding: edges(px(2)),
margin: edges({ kind: "Auto" }),
size: size2({ kind: "Percent", value: 100 }, { kind: "Content" }),
minSize: size2(px(0), px(0)),
maxSize: size2({ kind: "Auto" }, { kind: "Auto" }),
position: { kind: "Relative" },
inset: edges({ kind: "Auto" }),
overflow: { kind: "Visible" },
alignItems: { kind: "Stretch" },
alignSelf: { kind: "Auto" },
alignContent: { kind: "Start" },
justifyContent: { kind: "SpaceBetween" },
grow: 1,
shrink: 0,
basis: { kind: "Auto" },
gridColumns: [],
gridRows: [],
};
}
export const plainPaint = (): Paint => ({ background: null, border: null, radius: 0 });
export function boxNode(id: string, children: readonly Node[]): BoxNode {
return { common: common(id), style: style(), paint: plainPaint(), role: { kind: "Group" }, children };
}
export const boxed = (id: string, children: readonly Node[]): Node => ({ kind: "Box", ...boxNode(id, children) });
export const text = (id: string, content: string): Node => ({
kind: "Text",
common: common(id),
content: lit(content),
styleToken: "body",
wrap: true,
maxLines: null,
selectable: false,
});
export function invocation(): CommandInvocation {
return {
invocationId: "3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b",
surfaceKey: "thing:surface:provider.a.main",
surfaceVersion: 7,
command: "go",
payload: { schema: "gamecult.thing.test.go.v1", bytes: new Uint8Array([0x91, 0x01, 0xff, 0x00]) },
captured: [
{ node: "name-input", value: { kind: "Text", value: "Ada" } },
{ node: "level", value: { kind: "Number", value: 0.75 } },
{ node: "flag", value: { kind: "Bool", value: true } },
{ node: "pick", value: { kind: "Choice", value: "a" } },
],
client: "renderer.web",
issuedAt: PINNED_CLOCK,
delegation: "delegate-1",
};
}
export function bindAllFormats(): Bind[] {
const number: Format = { kind: "Number", precision: 2, percent: true, unit: "ms" };
const affix: Format = { kind: "Affix", prefix: "$", suffix: "/s" };
const indexed: MemberPath = { steps: [{ kind: "Name", name: "items" }, { kind: "Index", index: 3 }] };
return [
recordBind(indexed, number),
recordBind(path("elapsed"), { kind: "Duration" }),
recordBind(path("at"), { kind: "Timestamp" }),
recordBind(path("price"), affix),
recordBind(path("plain"), null),
itemBind(path("name")),
];
}
export function fullSurface(): SurfaceDocument {
const binds = bindAllFormats();
const boundText: TextSource = { kind: "Bound", ...binds[3] };
const boundBool: BoolSource = { kind: "Bound", ...binds[4] };
const boundNumber: NumberSource = { kind: "Bound", ...binds[0] };
const painted: Node = {
kind: "Box",
common: { id: "painted", visible: param("show"), a11yLabel: param("label") },
style: {
...style(),
display: { kind: "Grid" },
gridColumns: [{ kind: "Fr", value: 1 }, px(120)],
gridRows: [{ kind: "Content" }],
position: { kind: "Absolute" },
overflow: { kind: "Scroll" },
direction: { kind: "RowReverse" },
wrap: { kind: "WrapReverse" },
alignItems: { kind: "Baseline" },
alignSelf: { kind: "Center" },
alignContent: { kind: "SpaceAround" },
justifyContent: { kind: "SpaceEvenly" },
},
paint: {
background: { kind: "Token", name: "surface.raised" },
border: { color: { kind: "Rgba", r: 1, g: 2, b: 3, a: 255 }, width: 1.5 },
radius: 4,
},
role: { kind: "Region" },
children: [text("deep-text", "deep")],
};
const repeat: Node = {
kind: "Repeat",
common: common("rows"),
collection: { kind: "Records", provider: null, schema: "gamecult.thing.test.v1", keyPrefix: "row-" },
keyMember: path("id"),
template: {
kind: "Text",
common: common("row-name"),
content: { kind: "Bound", ...itemBind(path("name")) },
styleToken: "body",
wrap: false,
maxLines: 2,
selectable: true,
},
};
const leaves: Node[] = [
{
kind: "Text",
common: common("t-bound"),
content: boundText,
styleToken: "label",
wrap: true,
maxLines: null,
selectable: false,
},
{ kind: "Image", common: common("img"), catalog: "icons", asset: "gear", fit: { kind: "Cover" }, alt: lit("a gear") },
{
kind: "Button",
common: common("go"),
content: [text("go-label", "Go")],
onActivate: command("go"),
enabled: boundBool,
},
{ kind: "Toggle", common: common("flag"), checked: boundBool, onChange: param("toggle") },
{
kind: "Slider",
common: common("level"),
min: 0,
max: 1,
step: 0.25,
value: boundNumber,
commit: command("set-level"),
},
{
kind: "TextInput",
common: common("name-input"),
multiline: true,
inputKind: { kind: "Number" },
placeholder: lit("name"),
initial: param("initial"),
submit: command("rename"),
},
{
kind: "TextInput",
common: common("title-input"),
multiline: false,
inputKind: { kind: "Text" },
placeholder: lit("title"),
initial: param("initial"),
submit: command("retitle"),
},
{
kind: "Select",
common: common("pick"),
options: { kind: "Literal", options: [{ value: "a", label: "Alpha" }] },
value: binds[4],
onChange: command("pick"),
},
{
kind: "Select",
common: common("pick-bound"),
options: {
kind: "Bound",
collection: { kind: "Member", ...binds[0] },
valueMember: path("id"),
labelMember: path("name"),
},
value: binds[5],
onChange: command("pick"),
},
repeat,
{
kind: "Overlay",
common: common("menu"),
anchor: { kind: "Node", node: "go" },
modal: false,
dismiss: command("close"),
children: [text("menu-item", "one")],
},
{
kind: "Overlay",
common: common("dialog"),
anchor: { kind: "Viewport" },
modal: true,
dismiss: command("close"),
children: [],
},
{
kind: "Plot",
common: common("trend"),
series: binds[1],
mark: { kind: "Area" },
xMin: 0,
xMax: null,
yMin: null,
yMax: 100,
},
{ kind: "Embed", common: common("viewport"), slotKind: "world.scene", slotId: "main", width: 640, height: 480 },
{
kind: "Use",
common: common("kv"),
composition: { publisher: "thing.std", name: "key-value", version: 1 },
args: [{ name: "key", value: { kind: "Text", value: "k" } }],
},
];
let deep: Node = boxed("d4", [painted]);
for (const id of ["d3", "d2", "d1"]) deep = boxed(id, [deep]);
const children: Node[] = [deep, ...leaves];
for (const [id, mark] of [
["trend-line", { kind: "Line" }],
["trend-bar", { kind: "Bar" }],
] as const) {
children.push({
kind: "Plot",
common: common(id),
series: binds[2],
mark,
xMin: null,
xMax: null,
yMin: null,
yMax: null,
});
}
(["Contain", "Fill", "Unscaled"] as const).forEach((fit, index) => {
children.push({
kind: "Image",
common: common(`img-fit-${index}`),
catalog: "icons",
asset: "gear",
fit: { kind: fit },
alt: lit(""),
});
});
(["List", "ListItem", "Heading", "Dialog", "Toolbar"] as const).forEach((role, index) => {
const roleStyle: BoxStyle =
index === 0
? { ...style(), direction: { kind: "Row" }, alignContent: { kind: "Auto" }, justifyContent: { kind: "Stretch" } }
: style();
children.push({
kind: "Box",
common: common(`role-${index}`),
style: roleStyle,
paint: plainPaint(),
role: { kind: role },
children: [],
});
});
children.push({
kind: "Box",
common: common("block"),
style: {
...style(),
display: { kind: "Block" },
direction: { kind: "ColumnReverse" },
wrap: { kind: "Wrap" },
overflow: { kind: "Clip" },
alignContent: { kind: "Center" },
justifyContent: { kind: "End" },
alignItems: { kind: "End" },
alignSelf: { kind: "Start" },
basis: { kind: "Content" },
},
paint: plainPaint(),
role: { kind: "Group" },
children: [],
});
children.push({
kind: "Text",
common: common("t-param"),
content: param("title"),
styleToken: "title",
wrap: true,
maxLines: null,
selectable: false,
});
children.push({
kind: "Toggle",
common: { id: "literal-number", visible: param("v"), a11yLabel: null },
checked: litBool(false),
onChange: command("toggle"),
});
children.push({
kind: "Slider",
common: common("slider-literal"),
min: -1,
max: 1,
step: 0.5,
value: litNumber(0.5),
commit: command("set-level"),
});
children.push({
kind: "Slider",
common: common("slider-param"),
min: 0,
max: 10,
step: 1,
value: param("n"),
commit: command("set-level"),
});
const rows: CollectionSource = param("rows");
children.push({
kind: "Repeat",
common: common("rows-param"),
collection: rows,
keyMember: path("id"),
template: text("row-param", "r"),
});
return {
providerId: "provider.a",
label: "main",
version: 7,
title: "All arms",
root: boxed("root", children),
commands: [
{ id: "go", payloadSchema: "gamecult.thing.test.go.v1", captures: ["name-input", "level"], confirm: "Go now?" },
{ id: "close", payloadSchema: "gamecult.thing.test.close.v1", captures: [], confirm: null },
],
};
}
export function fullComposition(): CompositionDocument {
const args: Arg[] = [
{ name: "text", value: { kind: "Text", value: "t" } },
{ name: "number", value: { kind: "Number", value: 2.5 } },
{ name: "bool", value: { kind: "Bool", value: true } },
{ name: "bind", value: { kind: "Bind", ...recordBind(path("x"), null) } },
{ name: "collection", value: { kind: "Collection", source: param("rows") } },
{ name: "command", value: { kind: "Command", command: command("go") } },
{ name: "slot", value: { kind: "Slot", nodes: [text("slot-text", "inside"), boxed("slot-box", [])] } },
];
const innerUse: Node = {
kind: "Use",
common: common("inner"),
composition: { publisher: "thing.std", name: "card", version: 2 },
args,
};
return {
publisher: "provider.a",
name: "panel",
version: 3,
params: [
{ name: "title", kind: { kind: "Text" } },
{ name: "count", kind: { kind: "Number" } },
{ name: "on", kind: { kind: "Bool" } },
{ name: "source", kind: { kind: "Bind" } },
{ name: "rows", kind: { kind: "Collection" } },
{ name: "act", kind: { kind: "Command" } },
{ name: "body", kind: { kind: "Slot" } },
],
body: boxed("panel-root", [text("panel-title", "t"), innerUse]),
};
}
export function results(answered: CommandInvocation): [string, CommandResult][] {
return [
["result-pending", CommandResult.pending(answered, 1, "working")],
["result-completed", CommandResult.completed(answered, 2, 8, true, null)],
[
"result-completed-open-surface",
CommandResult.completed(answered, 3, null, false, { kind: "OpenSurface", key: "thing:surface:provider.a.next" }),
],
[
"result-completed-open-external",
CommandResult.completed(answered, 4, 9, true, {
kind: "OpenExternal",
url: "https://example.test/auth",
resume: command("resume"),
}),
],
["result-denied", CommandResult.denied(answered, 5, "not yours", "go")],
["result-denied-no-node", CommandResult.denied(answered, 6, "not allowed", null)],
["result-failed", CommandResult.failed(answered, 7, "broke")],
];
}
export function assetCatalogValue(): AssetCatalog {
return {
providerId: "provider.a",
catalogId: "icons",
version: 2,
assets: [
{ id: "gear", mediaType: "image/png", uri: "assets/gear.png", width: 32, height: 32 },
{ id: "bolt", mediaType: "image/svg+xml", uri: "assets/bolt.svg", width: 0, height: 0 },
],
};
}

View file

@ -1,37 +0,0 @@
// Every codec of the package is named GameCult.Thing.Contract.<RustName>, the reference's full type name, and every union
// names itself in its refusals. The exports are the lowerCamel of the Rust type names, so the test reads the expected name
// off the export.
import assert from "node:assert/strict";
import test from "node:test";
import { codecs } from "@gamecult/thing-contract";
const NS = "GameCult.Thing.Contract.";
const entries = Object.entries(codecs) as [string, { typeName?: string; decode(raw: unknown): unknown }][];
const rustName = (key: string) => key[0].toUpperCase() + key.slice(1);
test("the package exports ninety-five codecs", () => {
assert.equal(entries.length, 95);
});
test("every codec carries the reference's full type name", () => {
for (const [key, codec] of entries) assert.equal(codec.typeName, `${NS}${rustName(key)}`, key);
});
test("every union refuses an unknown arm by its own name, and the twenty-nine unions are all of them", () => {
const unions: string[] = [];
for (const [key, codec] of entries) {
let message: string;
try {
codec.decode([2 ** 30, []]);
continue;
} catch (error) {
message = (error as Error).message;
}
const named = /^Union (\w+): unknown arm key 1073741824 \(declared keys: [-\d, ]+\)\.$/.exec(message);
if (named === null) continue;
unions.push(key);
assert.equal(named[1], rustName(key), key);
}
assert.equal(unions.length, 29);
});

View file

@ -1,76 +0,0 @@
// The deepest tree the payload bound admits decodes and re-encodes on Node's default stack (no --stack-size), and one
// level deeper is refused with CultCachePayloadDepthError, never an abort. The Rust test of the same name needs a 16 MiB
// thread for this; here the stack holds, and the bound refuses first.
import assert from "node:assert/strict";
import test from "node:test";
import {
CultCachePayloadDepthError,
MAX_PAYLOAD_DEPTH,
decodeDocumentPayload,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
} from "@gamecult/cultcache-ts";
import { surfaceDocument } from "@gamecult/thing-contract";
import type { Node, SurfaceDocument } from "@gamecult/thing-contract";
import { boxNode, boxed, text } from "./builders.js";
function chain(levels: number): SurfaceDocument {
let root: Node = text("leaf", "bottom");
for (let depth = levels - 1; depth >= 0; depth--) root = boxed(`deep-${depth}`, [root]);
return { providerId: "provider.a", label: "deep", version: 1, title: "deep", root, commands: [] };
}
const admits = (levels: number): boolean => {
try {
encodeDocumentPayload(surfaceDocument, chain(levels));
return true;
} catch (error) {
assert.ok(error instanceof CultCachePayloadDepthError, "the only refusal of a plain chain is the depth bound");
assert.equal(error.limit, MAX_PAYLOAD_DEPTH);
return false;
}
};
test("the deepest Box chain the bound admits round-trips on the default stack, and one more is refused", () => {
let deepest = 1;
while (admits(deepest + 1)) deepest++;
assert.equal(deepest, 83, "the bound admits 83 Box levels above the text leaf, 84 nodes deep");
assert.ok(!admits(deepest + 1));
const value = chain(deepest);
const bytes = documentPayloadBytes(encodeDocumentPayload(surfaceDocument, value));
const decoded = decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(bytes));
assert.deepStrictEqual(decoded, value);
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(surfaceDocument, decoded)), bytes);
});
test("a payload one container past the bound is refused with the depth error, before any codec reads it", () => {
const nested = (levels: number) => new Uint8Array([...new Array<number>(levels).fill(0x91), 0xc0]);
assert.throws(
() => decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(nested(MAX_PAYLOAD_DEPTH + 1))),
(error: unknown) => error instanceof CultCachePayloadDepthError && error.depth === MAX_PAYLOAD_DEPTH + 1,
);
// At the bound the walk admits it and the codec refuses the shape instead.
assert.throws(
() => decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(nested(MAX_PAYLOAD_DEPTH))),
(error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError),
);
});
test("a wide tree of a thousand nodes round-trips", () => {
const children: Node[] = Array.from({ length: 998 }, (_, index) => text(`t${index}`, "x"));
children.push(boxed("tail", []));
const wide: SurfaceDocument = {
providerId: "provider.a",
label: "wide",
version: 1,
title: "wide",
root: { kind: "Box", ...boxNode("root", children) },
commands: [],
};
const bytes = documentPayloadBytes(encodeDocumentPayload(surfaceDocument, wide));
assert.deepStrictEqual(decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(bytes)), wide);
});

View file

@ -1,86 +0,0 @@
// The five definitions carry the crate's cultcache(type, schema) and, per document slot, the reference's member name
// and C# type name that a store's schema catalog is compared on.
import assert from "node:assert/strict";
import test from "node:test";
import {
assetCatalogDocument,
commandInvocationDocument,
commandResultDocument,
compositionDocument,
surfaceDocument,
thingContract,
} from "@gamecult/thing-contract";
const NS = "GameCult.Thing.Contract.";
const STRING = "System.String";
const list = (item: string) => `System.Collections.Generic.List<${NS}${item}>`;
const rustName: Record<string, string> = {
"gamecult.thing.surface": "SurfaceDocument",
"gamecult.thing.composition": "CompositionDocument",
"gamecult.thing.command_invocation": "CommandInvocation",
"gamecult.thing.command_result": "CommandResult",
"gamecult.thing.asset_catalog": "AssetCatalog",
};
const expected = [
[surfaceDocument, "gamecult.thing.surface", [
"0:ProviderId:" + STRING,
"1:Label:" + STRING,
"2:Version:System.Int64",
"3:Title:" + STRING,
`4:Root:${NS}Node`,
`5:Commands:${list("CommandDecl")}`,
]],
[compositionDocument, "gamecult.thing.composition", [
"0:Publisher:" + STRING,
"1:Name:" + STRING,
"2:Version:System.Int64",
`3:Params:${list("ParamDecl")}`,
`4:Body:${NS}Node`,
]],
[commandInvocationDocument, "gamecult.thing.command_invocation", [
"0:InvocationId:" + STRING,
"1:SurfaceKey:" + STRING,
"2:SurfaceVersion:System.Int64",
"3:Command:" + STRING,
`4:Payload:${NS}RecordPayload`,
`5:Captured:${list("CapturedInput")}`,
"6:Client:" + STRING,
"7:IssuedAt:" + STRING,
"8:Delegation:" + STRING,
]],
[commandResultDocument, "gamecult.thing.command_result", [
"0:InvocationId:" + STRING,
"1:Seq:System.Int32",
`2:Outcome:${NS}Outcome`,
]],
[assetCatalogDocument, "gamecult.thing.asset_catalog", [
"0:ProviderId:" + STRING,
"1:CatalogId:" + STRING,
"2:Version:System.Int64",
`3:Assets:${list("AssetEntry")}`,
]],
] as const;
for (const [definition, type, members] of expected) {
test(`${type}: type, schema and catalog members`, () => {
assert.equal(definition.type, type);
assert.equal(definition.schemaName, type);
assert.equal(definition.schemaVersion, `${type}.v1`);
assert.equal(definition.codec?.typeName, `${NS}${rustName[type]}`);
assert.deepEqual(
definition.members?.map((member) => `${member.slot}:${member.memberName}:${member.typeName}`),
members,
);
});
}
test("the registry lists the five definitions, in the crate's order", () => {
assert.deepEqual(
thingContract.definitions.map((definition) => definition.type),
expected.map(([, type]) => type),
);
assert.equal(new Set(thingContract.definitions).size, 5);
});

View file

@ -1,141 +0,0 @@
// A CommandResult exists only by a factory or by the decode door. Construction is attacked the way an outside consumer
// would: an object literal, JSON.parse output, structuredClone, spread, prototype tricks, the constructor, a Proxy. Each is
// refused where a result is encoded, and none is a CommandResult. The type-level half is test/types.ts.
import assert from "node:assert/strict";
import test from "node:test";
import { decodeDocumentPayload, documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload } from "@gamecult/cultcache-ts";
import { CommandResult, commandResultDocument } from "@gamecult/thing-contract";
import { CANARY, golden, invocation, results } from "./builders.js";
const answered = invocation();
const encodeResult = (value: unknown) => () => encodeDocumentPayload(commandResultDocument, value);
const BUILT = /a CommandResult is built by its factories/;
test("a factory copies the invocation id and holds the seq", () => {
const result = CommandResult.failed(answered, 5, "broke");
assert.equal(result.invocationId, answered.invocationId);
assert.equal(result.seq, 5);
assert.deepEqual(result.outcome, { kind: "Failed", reason: "broke" });
assert.ok(result instanceof CommandResult);
assert.ok(CommandResult.isResult(result));
});
test("each factory builds its outcome", () => {
assert.deepEqual(CommandResult.pending(answered, 1, "working").outcome, { kind: "Pending", message: "working" });
assert.deepEqual(CommandResult.completed(answered, 2, 8, true, null).outcome, {
kind: "Completed",
sourceVersion: 8,
clearDrafts: true,
effect: null,
});
assert.deepEqual(CommandResult.denied(answered, 3, "no", "go").outcome, { kind: "Denied", reason: "no", node: "go" });
});
test("a factory refuses seq below 1 naming Seq, and not the value", () => {
for (const bad of [0, -1, -(2 ** 31)]) {
for (const build of [
() => CommandResult.pending(answered, bad, "m"),
() => CommandResult.completed(answered, bad, null, false, null),
() => CommandResult.denied(answered, bad, "r", null),
() => CommandResult.failed(answered, bad, "r"),
]) {
assert.throws(build, (error: Error) => /Seq must be at least 1/.test(error.message) && !error.message.includes(String(bad)));
}
}
assert.throws(() => CommandResult.failed(answered, 1.5, "r"), /int32/);
assert.throws(() => CommandResult.failed(answered, 2 ** 31, "r"), /int32/);
assert.equal(CommandResult.failed(answered, 2 ** 31 - 1, "r").seq, 2 ** 31 - 1);
});
test("the decode door yields a CommandResult instance, equal to the factory's", () => {
for (const [file, value] of results(answered)) {
const decoded = decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden(`${file}.bin`)));
assert.ok(decoded instanceof CommandResult, file);
assert.ok(CommandResult.isResult(decoded), file);
assert.deepStrictEqual(decoded, value, file);
assert.ok(Object.isFrozen(decoded), file);
}
});
test("encoding a result a factory built succeeds, and re-decodes", () => {
const result = CommandResult.pending(answered, 1, "working");
const bytes = documentPayloadBytes(encodeDocumentPayload(commandResultDocument, result));
assert.deepEqual(bytes, golden("result-pending.bin"));
});
test("a copy of a result is not a result: structuredClone, spread and JSON.parse output are refused at encode", () => {
const result = CommandResult.failed(answered, 7, "broke");
for (const [name, copy] of [
["structuredClone", structuredClone(result)],
["spread", { ...result }],
["JSON.parse", JSON.parse(JSON.stringify(result)) as unknown],
["Object.assign", Object.assign({}, result)],
] as const) {
assert.ok(!CommandResult.isResult(copy), name);
assert.ok(!(copy instanceof CommandResult), name);
assert.throws(encodeResult(copy), BUILT, name);
}
});
test("an object literal is not a result", () => {
const literal = { invocationId: answered.invocationId, seq: 1, outcome: { kind: "Failed", reason: "broke" } };
assert.ok(!CommandResult.isResult(literal));
assert.throws(encodeResult(literal), BUILT);
for (const other of [null, undefined, 0, "", "completed", [], {}, () => 0]) {
assert.ok(!CommandResult.isResult(other));
assert.throws(encodeResult(other), BUILT);
}
});
test("prototype tricks do not make a result", () => {
const result = CommandResult.failed(answered, 7, "broke");
const viaCreate = Object.assign(Object.create(CommandResult.prototype) as object, result);
const viaSetPrototype = Object.setPrototypeOf({ ...result }, CommandResult.prototype) as object;
const viaProxy = new Proxy(result, {});
class Impostor extends (CommandResult as unknown as new () => object) {}
for (const [name, fake] of [["Object.create", viaCreate], ["setPrototypeOf", viaSetPrototype], ["Proxy", viaProxy]] as const) {
assert.ok(!CommandResult.isResult(fake), name);
assert.throws(encodeResult(fake), BUILT, name);
}
assert.throws(() => new Impostor(), BUILT);
});
test("the constructor is closed at run time as well as in types", () => {
const construct = CommandResult as unknown as new (...args: unknown[]) => CommandResult;
const outcome = { kind: "Failed", reason: "broke" };
assert.throws(() => new construct(answered.invocationId, 1, outcome), BUILT);
assert.throws(() => new construct(Symbol("door"), answered.invocationId, 1, outcome), BUILT);
assert.throws(() => new construct(undefined, answered.invocationId, 0, outcome), BUILT);
assert.throws(() => Reflect.construct(construct, [answered.invocationId, 0, outcome]), BUILT);
assert.throws(() => Reflect.construct(construct, [], CommandResult), BUILT);
});
test("a result is frozen: a script that ignores types cannot move its seq out of domain", () => {
const result = CommandResult.failed(answered, 7, "broke");
assert.ok(Object.isFrozen(result));
const writable = result as unknown as { seq: number; invocationId: string };
assert.throws(() => {
writable.seq = 0;
}, TypeError);
assert.throws(() => {
writable.invocationId = "other";
}, TypeError);
assert.equal(result.seq, 7);
assert.equal(result.invocationId, answered.invocationId);
});
test("an out-of-domain seq cannot cross the decode door, and no message echoes the payload", () => {
const message = (run: () => unknown) => {
try {
run();
} catch (error) {
return (error as Error).message;
}
return assert.fail("expected a refusal");
};
const refused = message(() => decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden("result-seq-zero.bin"))));
assert.match(refused, /Seq must be at least 1/);
assert.ok(!refused.includes(CANARY));
});

View file

@ -1,48 +0,0 @@
// The value goldens: each file is what the Rust crate writes for the builder's value. Encoding the ported builder gives
// the file byte for byte, decoding the file gives the value back, and re-encoding what was decoded gives the file again.
import assert from "node:assert/strict";
import test from "node:test";
import {
decodeDocumentPayload,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
} from "@gamecult/cultcache-ts";
import {
assetCatalogDocument,
commandInvocationDocument,
commandResultDocument,
compositionDocument,
surfaceDocument,
} from "@gamecult/thing-contract";
import { assetCatalogValue, fullComposition, fullSurface, golden, invocation, results } from "./builders.js";
type Case = [file: string, definition: Parameters<typeof encodeDocumentPayload>[0], value: unknown];
const answered = invocation();
const cases: Case[] = [
["surface-all-arms.bin", surfaceDocument, fullSurface()],
["composition-all-params.bin", compositionDocument, fullComposition()],
["invocation-all-inputs.bin", commandInvocationDocument, answered],
...results(answered).map(([name, value]): Case => [`${name}.bin`, commandResultDocument, value]),
["asset-catalog.bin", assetCatalogDocument, assetCatalogValue()],
];
test("the case table names the eleven value goldens", () => {
assert.equal(cases.length, 11);
assert.equal(new Set(cases.map(([file]) => file)).size, 11);
assert.equal(cases.filter(([, definition]) => definition === commandResultDocument).length, 7);
});
for (const [file, definition, value] of cases) {
test(`${file}: the builder encodes to the golden, the golden decodes to the builder, and re-encodes identical`, () => {
const committed = golden(file);
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(definition, value)), committed);
const decoded = decodeDocumentPayload(definition, documentPayloadFromBytes(committed));
assert.deepStrictEqual(decoded, value);
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(definition, decoded)), committed);
});
}

View file

@ -1,67 +0,0 @@
// The key grammar of crates/thing-contract/src/keys.rs and the cases of tests/result.rs: keys are built from the fields,
// every local segment is a Segment, and two different field sets never build one key.
import assert from "node:assert/strict";
import test from "node:test";
import { assetCatalogKey, compositionKey, invocationKey, resultKey, surfaceKey } from "@gamecult/thing-contract";
import { CANARY } from "./builders.js";
const builders: [string, (segment: string) => string][] = [
["surfaceKey", (segment) => surfaceKey("p", segment)],
["compositionKey name", (segment) => compositionKey("pub", segment, 1)],
["assetCatalogKey", (segment) => assetCatalogKey("p", segment)],
];
for (const [name, build] of builders) {
test(`${name} refuses an empty, a dot and a dotted segment, naming Segment and not the value`, () => {
assert.throws(() => build(""), /Segment must be non-empty/);
assert.throws(() => build("."), /Segment must be free of '\.'/);
assert.throws(() => build("a.b"), /Segment must be free of '\.'/);
assert.throws(
() => build(`${CANARY}.x`),
(error: Error) => /Segment/.test(error.message) && !error.message.includes(CANARY),
);
});
}
test("the keys are spelled from the fields", () => {
assert.equal(invocationKey("an-invocation-no-constructor-can-guess"), "thing:invocation:an-invocation-no-constructor-can-guess");
assert.equal(resultKey("an-invocation-no-constructor-can-guess", 3), "thing:result:an-invocation-no-constructor-can-guess.3");
assert.equal(surfaceKey("p", "main"), "thing:surface:p.main");
assert.equal(compositionKey("pub", "card", 2), "thing:composition:pub.card.v2");
assert.equal(compositionKey("pub", "card", 2n ** 40n), "thing:composition:pub.card.v1099511627776");
assert.equal(assetCatalogKey("p", "icons"), "thing:asset_catalog:p.icons");
});
test("a result key refuses a seq below 1 and a composition key a version below 0", () => {
assert.throws(() => resultKey("x", 0), /Seq must be at least 1/);
assert.throws(() => compositionKey("pub", "card", -1), /Version must be at least 0/);
assert.equal(compositionKey("pub", "card", 0), "thing:composition:pub.card.v0");
assert.throws(() => compositionKey("pub", "card", 1.5), /int64/);
assert.throws(() => resultKey("x", 1.5), /int32/);
assert.throws(() => resultKey("x", 2 ** 31), /int32/);
});
test("keys are injective over their field sets", () => {
// A dotted provider with a plain label, and a plain provider with a dotted label: the second cannot be built.
assert.equal(surfaceKey("a.b", "c"), "thing:surface:a.b.c");
assert.throws(() => surfaceKey("a", "b.c"), /Segment/);
assert.notEqual(surfaceKey("a", "c"), surfaceKey("a.b", "c"));
assert.notEqual(surfaceKey("a.b", "c"), surfaceKey("a", "bc"));
// A composition splits at its last two dots: publisher, name, then the version.
assert.equal(compositionKey("a.b", "c", 1), "thing:composition:a.b.c.v1");
assert.notEqual(compositionKey("a.b", "c", 1), compositionKey("a", "bc", 1));
assert.notEqual(compositionKey("a", "c", 1), compositionKey("a", "c", 11));
assert.notEqual(compositionKey("a", "c", 1), compositionKey("a", "c", 0));
// A catalog likewise, under the asset_catalog prefix.
assert.equal(assetCatalogKey("a.b", "c"), "thing:asset_catalog:a.b.c");
assert.notEqual(assetCatalogKey("a.b", "c"), assetCatalogKey("a", "bc"));
// A result splits at its last dot, and the seq is digits.
assert.equal(resultKey("x.1", 2), "thing:result:x.1.2");
assert.equal(resultKey("x", 12), "thing:result:x.12");
assert.notEqual(resultKey("x.1", 2), resultKey("x", 12));
});

View file

@ -1,148 +0,0 @@
// The refusal goldens and the domain doors. A payload is refused by name (the union and the arm key, or the domain), and
// no message echoes a value: the goldens carry CANARY in the slots a refusal must not repeat.
import assert from "node:assert/strict";
import test from "node:test";
import {
decodeDocumentPayload,
defineDocumentType,
documentPayloadFromBytes,
encodeDocumentPayload,
} from "@gamecult/cultcache-ts";
import {
CommandResult,
assetCatalogDocument,
codecs,
commandResultDocument,
domain,
surfaceDocument,
} from "@gamecult/thing-contract";
import { CANARY, assetCatalogValue, fullSurface, golden, invocation } from "./builders.js";
const rgbaDocument = defineDocumentType({
type: "test.rgba",
schema: { parse: (input: unknown) => input },
codec: codecs.rgba,
});
function refusal(run: () => unknown): string {
try {
run();
} catch (error) {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes(CANARY), `the refusal echoed a value: ${error.message}`);
return error.message;
}
return assert.fail("expected a refusal");
}
const read = (definition: Parameters<typeof decodeDocumentPayload>[0], file: string) => () =>
decodeDocumentPayload(definition, documentPayloadFromBytes(golden(file)));
test("a Thing 1 flat map is not a command result", () => {
refusal(read(commandResultDocument, "result-thing1-flat.bin"));
});
test("an unknown Node arm is refused naming the union and the key", () => {
assert.match(refusal(read(surfaceDocument, "node-unknown-arm.bin")), /Union Node: unknown arm key 13/);
});
test("an unknown Outcome arm is refused naming the union and the key", () => {
assert.match(refusal(read(commandResultDocument, "outcome-unknown-arm.bin")), /Union Outcome: unknown arm key 4/);
});
test("an extra trailing slot is read and never written back", () => {
const decoded = decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden("result-extra-slot.bin")));
const expected = CommandResult.pending({ ...invocation(), invocationId: "inv-1" }, 1, "working");
assert.deepStrictEqual(decoded, expected);
assert.ok(!JSON.stringify(decoded).includes(CANARY));
});
test("each domain golden is refused naming its domain", () => {
assert.match(refusal(read(commandResultDocument, "result-seq-zero.bin")), /Seq must be at least 1/);
assert.match(refusal(read(surfaceDocument, "surface-version-negative.bin")), /Version must be at least 0/);
assert.match(refusal(read(surfaceDocument, "surface-label-dotted.bin")), /Segment must be free of '\.'/);
assert.match(refusal(read(assetCatalogDocument, "asset-extent-negative.bin")), /Count must be at least 0/);
assert.match(refusal(read(rgbaDocument, "rgba-channel-out-of-range.bin")), /Rgba channel must be 0 to 255/);
});
test("the encode door holds the same domains", () => {
const surface = fullSurface();
const catalog = assetCatalogValue();
const encodeSurface = (value: unknown) => () => encodeDocumentPayload(surfaceDocument, value);
assert.match(refusal(encodeSurface({ ...surface, label: `${CANARY}.x` })), /Segment must be free of '\.'/);
assert.match(refusal(encodeSurface({ ...surface, label: "" })), /Segment must be non-empty/);
assert.match(refusal(encodeSurface({ ...surface, label: null })), /label/);
assert.match(refusal(encodeSurface({ ...surface, version: -1 })), /Version must be at least 0/);
assert.match(refusal(encodeSurface({ ...surface, version: -(2n ** 40n) })), /Version must be at least 0/);
assert.match(
refusal(() => encodeDocumentPayload(assetCatalogDocument, { ...catalog, assets: [{ ...catalog.assets[0], width: -5 }] })),
/Count must be at least 0/,
);
assert.match(
refusal(() => encodeDocumentPayload(rgbaDocument, { r: 256, g: 0, b: 0, a: 255 })),
/Rgba channel must be 0 to 255/,
);
assert.match(
refusal(() => encodeDocumentPayload(rgbaDocument, { r: 0, g: -1, b: 0, a: 255 })),
/Rgba channel must be 0 to 255/,
);
});
test("each domain accepts its boundary values and refuses the step beyond, on both doors", () => {
assert.equal(domain.seq.decode(1), 1);
assert.equal(domain.seq.decode(2 ** 31 - 1), 2 ** 31 - 1);
assert.throws(() => domain.seq.decode(0), /Seq must be at least 1/);
assert.throws(() => domain.seq.decode(2 ** 31), /int32/);
assert.throws(() => domain.seq.encode(0), /Seq must be at least 1/);
assert.equal(domain.seq.encode(1), 1);
assert.equal(domain.version.decode(0), 0);
assert.equal(domain.version.decode(2n ** 62n), 2n ** 62n);
assert.throws(() => domain.version.decode(-1), /Version must be at least 0/);
assert.throws(() => domain.version.encode(-1n), /Version must be at least 0/);
assert.equal(domain.version.encode(0), 0);
assert.equal(domain.count.decode(0), 0);
assert.throws(() => domain.count.decode(-1), /Count must be at least 0/);
assert.throws(() => domain.count.encode(-1), /Count must be at least 0/);
assert.equal(domain.count.encode(0), 0);
assert.equal(domain.segment.decode("a"), "a");
assert.throws(() => domain.segment.decode(""), /Segment must be non-empty/);
assert.throws(() => domain.segment.decode("."), /Segment must be free of '\.'/);
assert.throws(() => domain.segment.encode("a.b"), /Segment must be free of '\.'/);
assert.throws(() => domain.segment.encode(""), /Segment must be non-empty/);
assert.equal(domain.segment.encode("a"), "a");
assert.equal(domain.channel.decode(0), 0);
assert.equal(domain.channel.decode(255), 255);
assert.throws(() => domain.channel.decode(256), /Rgba channel must be 0 to 255/);
assert.throws(() => domain.channel.decode(-1), /Rgba channel must be 0 to 255/);
assert.throws(() => domain.channel.encode(256), /Rgba channel must be 0 to 255/);
assert.throws(() => domain.channel.encode(-1), /Rgba channel must be 0 to 255/);
assert.equal(domain.channel.encode(255), 255);
assert.equal(domain.channel.encode(0), 0);
});
test("a domain refusal never echoes the value", () => {
for (const run of [
() => domain.segment.decode(`${CANARY}.x`),
() => domain.seq.decode(-777),
() => domain.version.decode(-777),
() => domain.count.decode(-777),
() => domain.channel.decode(777),
() => domain.seq.encode(-777),
() => domain.channel.encode(777),
]) {
const message = refusal(run);
assert.ok(!message.includes("777"), message);
}
});
test("a domain codec reports the reference's leaf type name", () => {
assert.equal(domain.seq.typeName, "System.Int32");
assert.equal(domain.count.typeName, "System.Int32");
assert.equal(domain.channel.typeName, "System.Int32");
assert.equal(domain.version.typeName, "System.Int64");
assert.equal(domain.segment.typeName, "System.String");
assert.equal(domain.version.valueType, true);
assert.equal(domain.segment.valueType, true);
});

View file

@ -1,84 +0,0 @@
// surface.cc is a single-file store the Rust crate wrote with one record of each kind under a pinned clock. Opened through
// CultCache with the five definitions it yields five records, each re-encodes to its value golden's bytes, and each record
// key equals the key keys.ts builds from the value.
import assert from "node:assert/strict";
import { join } from "node:path";
import test from "node:test";
import { CultCache, SingleFileMessagePackBackingStore, documentPayloadBytes } from "@gamecult/cultcache-ts";
import {
CommandResult,
assetCatalogDocument,
assetCatalogKey,
commandInvocationDocument,
commandResultDocument,
compositionDocument,
compositionKey,
invocationKey,
resultKey,
surfaceDocument,
surfaceKey,
thingContract,
} from "@gamecult/thing-contract";
import { PINNED_CLOCK, assetCatalogValue, fullComposition, fullSurface, golden, goldensDir, invocation } from "./builders.js";
async function open() {
const cache = CultCache.builder()
.withRegistry(thingContract)
.withGenericStore(new SingleFileMessagePackBackingStore(join(goldensDir, "surface.cc")))
.build();
await cache.pullAllBackingStores();
return cache;
}
test("surface.cc opens to one record of each of the five kinds", async () => {
const cache = await open();
const snapshot = cache.snapshot();
assert.equal(snapshot.length, 5);
assert.deepEqual(
snapshot.map((envelope) => envelope.type).sort(),
[
"gamecult.thing.asset_catalog",
"gamecult.thing.command_invocation",
"gamecult.thing.command_result",
"gamecult.thing.composition",
"gamecult.thing.surface",
],
);
for (const envelope of snapshot) assert.equal(envelope.storedAt, PINNED_CLOCK);
});
test("each record equals its value and re-encodes to its value golden", async () => {
const cache = await open();
const surface = fullSurface();
const composition = fullComposition();
const answered = invocation();
const result = CommandResult.pending(answered, 1, "working");
const catalog = assetCatalogValue();
const surfaceRecordKey = surfaceKey(surface.providerId, surface.label);
const compositionRecordKey = compositionKey(composition.publisher, composition.name, composition.version);
const invocationRecordKey = invocationKey(answered.invocationId);
const resultRecordKey = resultKey(result.invocationId, result.seq);
const catalogRecordKey = assetCatalogKey(catalog.providerId, catalog.catalogId);
assert.deepStrictEqual(cache.getRequired(surfaceDocument, surfaceRecordKey), surface);
assert.deepStrictEqual(cache.getRequired(compositionDocument, compositionRecordKey), composition);
assert.deepStrictEqual(cache.getRequired(commandInvocationDocument, invocationRecordKey), answered);
assert.deepStrictEqual(cache.getRequired(commandResultDocument, resultRecordKey), result);
assert.deepStrictEqual(cache.getRequired(assetCatalogDocument, catalogRecordKey), catalog);
const goldenOf: Record<string, string> = {
[surfaceRecordKey]: "surface-all-arms.bin",
[compositionRecordKey]: "composition-all-params.bin",
[invocationRecordKey]: "invocation-all-inputs.bin",
[resultRecordKey]: "result-pending.bin",
[catalogRecordKey]: "asset-catalog.bin",
};
const snapshot = cache.snapshot();
assert.deepEqual(snapshot.map((envelope) => envelope.key).sort(), Object.keys(goldenOf).sort());
for (const envelope of snapshot) {
assert.deepEqual(documentPayloadBytes(envelope.payload), golden(goldenOf[envelope.key]), envelope.key);
}
});

View file

@ -1,45 +0,0 @@
// Compile-time half of the construction rules, compiled by tsconfig.test.json: every @ts-expect-error line must be an
// error, or the compile fails. This file runs nothing.
import { CommandResult } from "@gamecult/thing-contract";
import type { CommandInvocation, Handler, Outcome } from "@gamecult/thing-contract";
declare const invocation: CommandInvocation;
declare const result: CommandResult;
declare const plain: { invocationId: string; seq: number; outcome: Outcome };
// An object literal does not satisfy CommandResult, because the class carries a private brand.
// @ts-expect-error
export const literal: CommandResult = { invocationId: "x", seq: 1, outcome: { kind: "Failed", reason: "r" } };
// Nor does a plain value of the same shape, nor a spread of a result.
// @ts-expect-error
export const shaped: CommandResult = plain;
// @ts-expect-error
export const spread: CommandResult = { ...result };
// The constructor is private: even a call whose arguments all type-check (the door argument is typed `never` here) is
// refused, so the error is the access, not the arity.
// @ts-expect-error
export const constructed = new CommandResult(undefined as never, "x", 1, { kind: "Failed", reason: "r" });
// The fields are read-only.
// @ts-expect-error
result.seq = 2;
// @ts-expect-error
result.invocationId = "y";
// @ts-expect-error
result.outcome = { kind: "Failed", reason: "r" };
// An Outcome is a tagged value, never a state string.
// @ts-expect-error
export const stringOutcome: Outcome = "completed";
// @ts-expect-error
export const stringFactory = CommandResult.failed(invocation, 1, "r", "completed", 3);
// @ts-expect-error
export const noInvocation = CommandResult.failed("inv-1", 1, "r");
// @ts-expect-error
export const stateInHandler: Handler = (): string => "completed";
// The positive cases compile.
export const built: CommandResult = CommandResult.failed(invocation, 1, "r");
export const handler: Handler = async () => ({ kind: "Pending", message: "m" });

View file

@ -1,14 +0,0 @@
{
"compilerOptions": {
"declaration": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noEmitOnError": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*.ts"]
}

View file

@ -1,14 +0,0 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noEmitOnError": true,
"outDir": "dist-test",
"rootDir": ".",
"strict": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["test/**/*.ts", "src/**/*.ts"]
}