Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/name-the-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
The docs show how to name a game's schema type, so type errors print `QueryEntity<GameSchema, ...>` instead of every component, resource, and event the game declares. In a game of 64 components an error used to start with `SchemaDefinition<{ readonly Position: Descriptor<...>; ... 62 more ...` and was cut off before it reached the query. `SETUP.md` (step 4) and `Schema.bind` show the pattern; a game that already binds works unchanged, and type-checking takes the same time:

```ts
const schema = Schema.fragment({ components: { Position, Velocity } }) // or Schema.bind(Core, Combat).schema
const schema = Schema.fragment({ components: { Position, Velocity } }) // or Schema.bind(Core, Combat, Root).schema
type SchemaOf = typeof schema
export interface GameSchema extends SchemaOf {}
const named: GameSchema = schema
Expand Down
5 changes: 5 additions & 0 deletions .changeset/root-mandatory-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typeonce/ritmo-browser": patch
---

The `Controls` and `InputCapture` examples declare their game's root and pass it to `Schema.bind`, which now takes one.
5 changes: 5 additions & 0 deletions .changeset/root-mandatory-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typeonce/ritmo-render": patch
---

The `RenderSync.interpolate` example declares its game's root and passes it to `Schema.bind`, which now takes one.
35 changes: 35 additions & 0 deletions .changeset/root-mandatory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@typeonce/ritmo": minor
---

**Breaking: `Schema.bind` takes the game's root, always.** Without a root, a game was branded by its schema type, which is covariant: a game and a game whose schema is a superset were interchangeable, so a system, schedule, query, or entity id of one compiled in the other, and a system that spawned and queried `Velocity` ran inside a runtime with no `Velocity`. Declare the root with `Schema.defineRoot` and pass it last:

```diff
-const Game = Schema.bind(Schema.fragment({ components: { Position } }))
+const Root = Schema.defineRoot("Game")
+const Game = Schema.bind(Schema.fragment({ components: { Position } }), Root)
```

```diff
-const Game = Schema.bind(Core, Combat)
+const Root = Schema.defineRoot("Game")
+const Game = Schema.bind(Core, Combat, Root)
```

This replaces 0.7's `Schema.merge` fix: `Schema.bind(A, B)` is now `Schema.bind(A, B, Root)`. A call without a root fails with `Schema.bind takes the game's root last: const Root = Schema.defineRoot('Game'), then Schema.bind(...fragments, Root)`. Values of games with different roots do not mix: the error names both, `Type 'RootToken<"Editor">' is not assignable to type 'RootToken<"Game">'`.

The root's name is its identity: two `defineRoot("Game")` calls, in one module or in two, make the same type, and games bound under them mix as one game would. Declare one root per game, under a name no other game in the program uses, and import that constant where descriptors name it (`Entity.Handle<typeof Root>`).

Types a game spelled with the schema in the root's place name the root instead:

```diff
-type Id = Entity.EntityId<typeof Game.schema, typeof Game.schema>
+type Id = Entity.EntityId<typeof Game.schema, typeof Root>
```

The same goes for `Command.SpawnedId`, `Schema.Schema.BoundSchedule`, `Schema.Schema.BoundStateMachine`, `EntityScope.EntityScope`, and the other types that take a root. `Schema.Schema.Game<S, Root>` no longer defaults `Root` to `S`. `Entity.isHandle` takes the root, not the schema:

```diff
-const isHandle = Entity.isHandle(Game.schema)
+const isHandle = Entity.isHandle(Root)
```
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ const Time = Clock.resource("Example/Time", step)
const Position = Descriptor.Component<Vec>()("Example/Position")
const Velocity = Descriptor.Component<Vec>()("Example/Velocity")

const Root = Schema.defineRoot("Example")
// Bind the schema to its game-facing APIs.
const Game = Schema.bind(
Schema.fragment({
components: { Position, Velocity },
resources: { Time }
})
}),
Root
)

const AdvanceTime = Clock.system(Game, {
Expand Down
3 changes: 2 additions & 1 deletion bench/compare/src/ritmo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const Tags = {
const Position = Descriptor.Component<{ readonly x: number; readonly y: number }>()("Bench/Position")
const Velocity = Descriptor.Component<{ readonly x: number; readonly y: number }>()("Bench/Velocity")

const Game = Schema.bind(Schema.fragment({ components: { A, B, C, D, E, Data, Position, Velocity, ...Tags } }))
const Root = Schema.defineRoot("Bench")
const Game = Schema.bind(Schema.fragment({ components: { A, B, C, D, E, Data, Position, Velocity, ...Tags } }), Root)

const runtime = () => {
const made = Game.Runtime.make({ services: Game.Runtime.services() })
Expand Down
3 changes: 2 additions & 1 deletion integration/algorithms/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ type FragmentOf = typeof fragment
/** The game's schema by name: type errors print `GameSchema`, not every descriptor. */
export interface GameSchema extends FragmentOf {}
const named: GameSchema = fragment
export const Game = Schema.bind(named)
const Root = Schema.defineRoot("Algorithms")
export const Game = Schema.bind(named, Root)
10 changes: 6 additions & 4 deletions packages/browser/dtslint/Controls.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const mouseBindings = { jump: ["primary"] } as const
const mouse = Pointer.actions(canvas, window, mouseBindings)
const keysWithoutPause = Keyboard.actions(host, { jump: [" "] })

const Root = Schema.defineRoot("ControlsTypes")

describe("Controls", () => {
it("types merged actions by the declared names", () => {
const input = Actions.merge({ keyboard, gamepad }).snapshot()
Expand Down Expand Up @@ -87,7 +89,7 @@ describe("Controls", () => {
}) => Actions.merge(devices)
const ControlsInput = Descriptor.Service<ReturnType<typeof makeControls>>()("ControlsTypes/Controls")
const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof ControlsInput>>()("ControlsTypes/Input")
const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
const Capture = InputCapture.system(Game, { name: "ControlsTypes/Capture", source: ControlsInput, resource: Input })
const runtime = Game.Runtime.make({
services: Game.Runtime.services(Game.Runtime.service(ControlsInput, makeControls({ keyboard, gamepad }))),
Expand All @@ -104,7 +106,7 @@ describe("Controls", () => {
it("rejects snapshot() through a plain service, and keeps the other methods", () => {
const makeControls = (devices: { readonly keyboard: Keyboard.Actions<typeof keyBindings> }) => Actions.merge(devices)
const ControlsInput = Descriptor.Service<ReturnType<typeof makeControls>>()("ControlsTypes/Plain")
const Game = Schema.bind(Schema.fragment({}))
const Game = Schema.bind(Schema.fragment({}), Root)
Game.System("ControlsTypes/Settings", { services: { controls: Game.System.service(ControlsInput) } }, ({ services }) => {
expect(services.controls.rebind.keyboard({ jump: ["w"], pause: ["p"] })).type.toBe<void>()
// @ts-expect-error snapshot() drains ControlsTypes/Plain: declare it with Game.System.consumeService, or read the resource its capture writes
Expand Down Expand Up @@ -170,7 +172,7 @@ describe("Controls", () => {
Actions.merge({ desktop: Actions.group(devices), gamepad })
const ControlsInput = Descriptor.Service<ReturnType<typeof makeControls>>()("ControlsTypes/Grouped")
const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof ControlsInput>>()("ControlsTypes/GroupedInput")
const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
const services = Game.Runtime.services(Game.Runtime.service(ControlsInput, makeControls({ keyboard, mouse })))
const idle = Actions.idle({ keyboard: Keyboard.idle(keyBindings), mouse: Pointer.idle(mouseBindings), gamepad: Gamepad.idle(padBindings) })
Game.Runtime.make({ services, resources: { Input: idle } })
Expand Down Expand Up @@ -247,7 +249,7 @@ describe("one home for the shared input types", () => {
expect(makeControls({ keyboard, gamepad })).type.toBeAssignableTo<DescriptorOf.Value<typeof ControlsInput>>()
// @ts-expect-error Property 'rebind' does not exist
;({} as DescriptorOf.Value<typeof ControlsInput>).rebind
const Game = Schema.bind(Schema.fragment({}))
const Game = Schema.bind(Schema.fragment({}), Root)
Game.System("ControlsTypes/Peek", { services: { controls: Game.System.service(ControlsInput) } }, ({ services }) => {
// @ts-expect-error snapshot() drains ControlsTypes/Source
services.controls.snapshot()
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/dtslint/InputCapture.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const Optional = Descriptor.TransientResource<{
const Wide = Descriptor.TransientResource<unknown>()("CaptureTypes/Wide")
const Declared = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof KeyboardInput>>()("CaptureTypes/Declared")
const Clock = Descriptor.Service<{ readonly now: () => number }>()("CaptureTypes/Clock")
const Game = Schema.bind(Schema.fragment({ resources: { Input, Wrong, Fewer, Optional, Wide, Declared } }))
const Root = Schema.defineRoot("CaptureTypes")
const Game = Schema.bind(Schema.fragment({ resources: { Input, Wrong, Fewer, Optional, Wide, Declared } }), Root)

describe("InputCapture", () => {
it("requires the service and resource at tick time", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/browser/dtslint/Players.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const makePlayers = (devices: {
readonly pads: Gamepad.Pads<typeof padKeys>
}) => Players.pool(devices)

const Root = Schema.defineRoot("PlayersTypes")

describe("Controls.players", () => {
it("types every slot by name, with device data behind a check of device", () => {
const pool = makePlayers({ left: Keyboard.actions(host, leftKeys), right: Keyboard.actions(host, rightKeys), pads: Gamepad.pads(host, padKeys, 4) })
Expand Down Expand Up @@ -82,7 +84,7 @@ describe("Controls.players", () => {
it("captures into a resource initialized with idle, and keeps snapshot() to the capture", () => {
const PlayersInput = Descriptor.Service<ReturnType<typeof makePlayers>>()("PlayersTypes/Players")
const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof PlayersInput>>()("PlayersTypes/Input")
const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
const pool = makePlayers({ left: Keyboard.actions(host, leftKeys), right: Keyboard.actions(host, rightKeys), pads: Gamepad.pads(host, padKeys, 2) })
Game.Runtime.make({
services: Game.Runtime.services(Game.Runtime.service(PlayersInput, pool)),
Expand Down
4 changes: 3 additions & 1 deletion packages/browser/dtslint/Pointer.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ declare const blurHost: Pointer.BlurHost

const bindings = { fire: ["primary"], aim: ["secondary", "back"] } as const

const Root = Schema.defineRoot("PointerTypes")

describe("Pointer", () => {
it("types snapshot actions by the bound action names, with raw buttons and the position", () => {
const mouse = Pointer.actions(canvas, blurHost, bindings)
Expand Down Expand Up @@ -53,7 +55,7 @@ describe("Pointer", () => {

it("takes snapshots only through consumeService", () => {
const MouseInput = Descriptor.Service<Pointer.Actions<typeof bindings>>()("PointerTypes/Mouse")
const Game = Schema.bind(Schema.fragment({}))
const Game = Schema.bind(Schema.fragment({}), Root)
Game.System("PointerTypes/Settings", { services: { mouse: Game.System.service(MouseInput) } }, ({ services }) => {
expect(services.mouse.rebind).type.toBeCallableWith({ fire: ["middle"], aim: ["primary"] })
// @ts-expect-error snapshot() drains PointerTypes/Mouse
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/Controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
* // Only the snapshot: a replay or the fuzzer provides it in place of the devices.
* const ControlsInput = Descriptor.Service<InputCapture.SourceOf<ReturnType<typeof makeControls>>>()("Game/Controls")
* const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof ControlsInput>>()("Game/Input")
* const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
* const Root = Schema.defineRoot("Game")
* const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
* const CaptureInput = InputCapture.system(Game, { name: "Game/CaptureInput", source: ControlsInput, resource: Input })
*
* const runtime = Game.Runtime.make({
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/InputCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
*
* const KeyboardInput = Descriptor.Service<InputCapture.SourceOf<Keyboard.Actions<typeof bindings>>>()("Game/Keyboard")
* const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof KeyboardInput>>()("Game/Input")
* const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
* const Root = Schema.defineRoot("Game")
* const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
*
* const CaptureInput = InputCapture.system(Game, { name: "Game/CaptureInput", source: KeyboardInput, resource: Input })
* const Jump = Game.System("Game/Jump", { resources: { input: Game.System.readResource(Input) } }, ({ resources }) => {
Expand Down
6 changes: 4 additions & 2 deletions packages/browser/test/Controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const Actions = Controls.actions({ buttons: ["jump", "pause"] })
const merged = (keys: Keyboard.Timeline<typeof keyBindings>, pad: Gamepad.Timeline) =>
Actions.merge({ keyboard: Keyboard.scripted(keyBindings, keys), gamepad: Gamepad.scripted(padBindings, pad) })

const Root = Schema.defineRoot("ControlsTest")

describe("Controls", () => {
it("merges an action like one device with every binding", () => {
const controls = merged(
Expand Down Expand Up @@ -102,7 +104,7 @@ describe("Controls", () => {
Actions.merge({ keyboard: devices.keyboard, touch: Actions.lacking(devices.touch, ["pause"]) })
const ControlsInput = Descriptor.Service<ReturnType<typeof makeControls>>()("ControlsTest/Controls")
const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof ControlsInput>>()("ControlsTest/Input")
const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
const Capture = InputCapture.system(Game, { name: "ControlsTest/Capture", source: ControlsInput, resource: Input })
const CaptureAgain = InputCapture.system(Game, { name: "ControlsTest/CaptureAgain", source: ControlsInput, resource: Input })
const runtime = Game.Runtime.make({
Expand Down Expand Up @@ -195,7 +197,7 @@ describe("Controls groups", () => {
})
expect(controls.snapshot().available).toEqual({ moveRight: true, fire: true, pause: false })
const Lints = Descriptor.Service<typeof controls>()("ControlsGroups/Controls")
const Game = Schema.bind(Schema.fragment({}))
const Game = Schema.bind(Schema.fragment({}), Root)
const runtime = Game.Runtime.make({
services: Game.Runtime.services(Game.Runtime.service(Lints, controls)),
resources: {},
Expand Down
6 changes: 4 additions & 2 deletions packages/browser/test/InputCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { Descriptor, Schema } from "@typeonce/ritmo"

const bindings = { jump: [" "], left: ["ArrowLeft"] } as const

const Root = Schema.defineRoot("CaptureTest")

describe("InputCapture", () => {
it("copies one snapshot per run into the resource", () => {
const Source = Descriptor.Service<InputCapture.Source<Keyboard.Snapshot<typeof bindings>>>()("CaptureTest/Source")
const Input = Descriptor.TransientResource<Keyboard.Snapshot<typeof bindings>>()("CaptureTest/Input")
const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)

const queued: Array<Keyboard.Snapshot<typeof bindings>> = [
{ actions: { jump: { held: true, pressed: true, released: false }, left: { held: false, pressed: false, released: false } }, vectors: {}, used: true },
Expand All @@ -35,7 +37,7 @@ describe("InputCapture", () => {
it("skips capture while its run conditions fail, keeping presses in the device", () => {
const Source = Descriptor.Service<Keyboard.Actions<typeof bindings>>()("CaptureTest/Keys")
const Input = Descriptor.TransientResource<Keyboard.Snapshot<typeof bindings>>()("CaptureTest/Held")
const Game = Schema.bind(Schema.fragment({ resources: { Input } }))
const Game = Schema.bind(Schema.fragment({ resources: { Input } }), Root)
const Pace = Game.StateMachine("CaptureTest/Pace", { Running: {}, Frozen: {} })
const keyboard = Keyboard.scripted(bindings, [{ frame: 0, press: ["jump"], release: ["jump"] }])
const Capture = InputCapture.system(Game, {
Expand Down
4 changes: 3 additions & 1 deletion packages/browser/test/Players.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const Players = Controls.players(Actions, { slots: ["p1", "p2"], join: "join", l

const tap = (frame: number, name: Keyboard.TimelineName<typeof leftKeys>) => ({ frame, press: [name], release: [name] })

const Root = Schema.defineRoot("PlayersTest")

describe("Controls.players", () => {
it("joins the first free slot on a join press, with that snapshot idle", () => {
const pool = Players.pool({
Expand Down Expand Up @@ -183,7 +185,7 @@ describe("Controls.players", () => {
Actions.merge({ keyboard: reused })
const pool = Players.pool({ left: reused, right: Keyboard.scripted(rightKeys, []), other: shared })
const PoolService = Descriptor.Service<typeof pool>()("PlayersTest/Pool")
const Game = Schema.bind(Schema.fragment({}))
const Game = Schema.bind(Schema.fragment({}), Root)
const runtime = Game.Runtime.make({ services: Game.Runtime.services(Game.Runtime.service(PoolService, pool)), resources: {}, debug: { hostClock: HostClock.test() } })
if (!runtime.ok) throw new Error("runtime")
const messages = runtime.value.debug.describe().lints.filter((lint) => lint.code === "service-lint").map((lint) => lint.message)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ pnpm add @typeonce/ritmo
import { Descriptor, Schema } from "@typeonce/ritmo"

const Position = Descriptor.Component<{ x: number; y: number }>()("Example/Position")
const Game = Schema.bind(Schema.fragment({ components: { Position } }))
const Root = Schema.defineRoot("Example")
const Game = Schema.bind(Schema.fragment({ components: { Position } }), Root)
```

A component or resource can say what its number fields mean with `meta`:
Expand Down
3 changes: 2 additions & 1 deletion packages/core/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ Follow these steps once in a new project, and for any step an existing project i
After every Ritmo upgrade, compare `.oxlintrc.json` with that README's config: a new rule is not on until the config lists it, so an existing project does not get it by upgrading.
4. Name the schema's type where the game binds it, so type errors print `GameSchema` instead of every descriptor the game has:
```ts
const schema = Schema.fragment({ components: { Position, Velocity } }) // or Schema.bind(Core, Combat).schema
const Root = Schema.defineRoot("Game")
const schema = Schema.fragment({ components: { Position, Velocity } }) // or Schema.bind(Core, Combat, Root).schema
type SchemaOf = typeof schema
export interface GameSchema extends SchemaOf {}
const named: GameSchema = schema
Expand Down
4 changes: 3 additions & 1 deletion packages/core/bench/helpers.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ const randomNext: BenchCase = {
}
}

const Root = Schema.defineRoot("Bench")

const randomDraw: BenchCase = {
name: "random/draw-resource-100k",
description: "Draw 100,000 random numbers from a resource in one system",
setup: () => {
const Rng = Descriptor.ConstructedResource(Random.codec)("Bench/Rng")
const Game = Schema.bind(Schema.fragment({ resources: { Rng } }))
const Game = Schema.bind(Schema.fragment({ resources: { Rng } }), Root)
const made = Game.Runtime.make({ services: Game.Runtime.services(), resources: { Rng: Random.seed(1) } })
if (!made.ok) throw new Error("bench runtime")
const runtime = made.value
Expand Down
Loading
Loading