From 04d2a9d01b22c35c85a3e76a743160c763a81519 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Tue, 10 Nov 2026 16:50:26 +0900 Subject: [PATCH] Release notes from the game upgrades, and two messages that name the fix Migrating inkline-town and spirit-call to the 0.23 release found gaps in what npm ships: - A tuple entry built outside the call widened to an array and failed with "${string} is not a component of this game's schema". It now says the entry widened, and names `as const` and `Command.Entry`. - `set` on an entity state with `transitions` names `setRaw` for data from outside the simulation. - Changesets: `cancelPending()` instead of the renamed `reset()`, the RenderSync rebuild trap and its second fix, widened tuples, the restore trap for transient resources and `nextStates` in every save, and replays recorded with a half-step `Clock.duration`. - The upgrade playbook lists what 0.23 changes without a compile error. Co-Authored-By: Claude Opus 5.5 --- .changeset/clock-steps-raw.md | 2 +- .changeset/exact-entries.md | 10 ++++++++ .changeset/render-node-follows-renderable.md | 17 +++++++++++++- .changeset/runtime-robustness.md | 2 +- .changeset/saves-and-loads.md | 4 ++-- .changeset/widened-entries-and-set-raw.md | 7 ++++++ packages/core/dtslint/CommandEntries.tst.ts | 17 ++++++++++++++ packages/core/dtslint/EntityState.tst.ts | 2 +- .../ritmo/playbooks/state-and-upgrades.md | 6 +++++ packages/core/src/Command.ts | 23 +++++++++++++++++-- packages/core/src/Query.ts | 15 ++++++------ 11 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 .changeset/widened-entries-and-set-raw.md diff --git a/.changeset/clock-steps-raw.md b/.changeset/clock-steps-raw.md index deb79479..678d6430 100644 --- a/.changeset/clock-steps-raw.md +++ b/.changeset/clock-steps-raw.md @@ -12,4 +12,4 @@ Literal counts (`Clock.durationOfSteps(step, 3)`) compile as before. A union of literals (`flag ? 3 : 0`) and a literal past `Number.MAX_SAFE_INTEGER` no longer compile either. -**`Clock.duration` and `Clock.durationRaw` now round a half step up, as documented.** A number of seconds whose exact product with the rate is a whole step and a half often landed just below it in floating point and rounded down. These durations are now one step longer, which changes a game's timing where it uses one: `1.025s` at 60 Hz is 62 steps (was 61), `2.05s` at 30 Hz is 62 (was 61), `0.145s` at 100 Hz is 15 (was 14), and `0.29s`, `0.57s`, `1.13s` at 50 Hz are 15, 29, and 57 (were 14, 28, and 56). 57 durations of up to three decimals and 5 seconds moved across 20, 30, 50, 60, 100, 120, 144, and 240 Hz; every other duration is the same step count as before. +**`Clock.duration` and `Clock.durationRaw` now round a half step up, as documented.** A number of seconds whose exact product with the rate is a whole step and a half often landed just below it in floating point and rounded down. These durations are now one step longer, which changes a game's timing where it uses one: `1.025s` at 60 Hz is 62 steps (was 61), `2.05s` at 30 Hz is 62 (was 61), `0.145s` at 100 Hz is 15 (was 14), and `0.29s`, `0.57s`, `1.13s` at 50 Hz are 15, 29, and 57 (were 14, 28, and 56). 57 durations of up to three decimals and 5 seconds moved across 20, 30, 50, 60, 100, 120, 144, and 240 Hz; every other duration is the same step count as before. Replays and fuzz cases recorded with one of these durations diverge: record them again. diff --git a/.changeset/exact-entries.md b/.changeset/exact-entries.md index a2b97c47..3b1e053b 100644 --- a/.changeset/exact-entries.md +++ b/.changeset/exact-entries.md @@ -11,6 +11,16 @@ | `[Position, { x: 1, y: "2" }]` | `Type 'string' is not assignable to type 'number & FieldOf<"Game/Position", "y">'` | | `[Outside, { x: 1 }]` (not in the schema) | `... is not assignable to type '"Game/Outside is not a component of this game's schema: spawn and insert take components registered in Schema.fragment({ components: { ... } })"'` | +A tuple built outside the call (returned by a helper, pushed into an array) widens to an array and fails with `this entry widened to an array: ...`. Add `as const` where it is built, or annotate it with `Command.Entry` (from `@typeonce/ritmo/Command`): + +```diff +-const wear = () => [[Risk, { fire: 0 }]] ++const wear = () => [[Risk, { fire: 0 }]] as const +-const roles = [] ++const roles: Array> = [] + roles.push([Hp, 1]) +``` + Values with an index signature, arrays, tuples, primitives (branded too), `Map`s, `Set`s, functions, `unknown`, and unions of shapes (any field of any member) are accepted as before, and a value written in place is still typed by the schema (`[Ease, (t) => t * 2]`, `[Seen, new Set()]`). Fix the field, or drop it. `Game.Command.entry(Descriptor, value)` built the same tuple, a second way to do one job that was strict where the tuple was not: write the tuple. `Game.Command.entryRaw` stays, for raw input that returns a `Result`. The message for writing an immutable component through a query now reads `replace it with commands.insert(entity, [Game/Kind, value])`. diff --git a/.changeset/render-node-follows-renderable.md b/.changeset/render-node-follows-renderable.md index 77bac9d8..8c789d4e 100644 --- a/.changeset/render-node-follows-renderable.md +++ b/.changeset/render-node-follows-renderable.md @@ -2,7 +2,9 @@ "@typeonce/ritmo-render": minor --- -**Breaking: a `RenderSync` node follows its renderable.** A node used to be made once per entity id from the renderable's value at that time and only applied after, so an entity whose renderable a system changed (a relabel, an evolve) kept the node of its old value, and after a rewind an entity spawned under an id already drawn (an enemy where a predicted bullet was) was drawn with the other entity's node for its whole life. Now, when an entity's renderable value is no longer the one its node was made from, `RenderSync.system` destroys the node, calls `create` with the new value, and then `apply`. Writing an equal value keeps the node and runs no extra `apply`: values are compared as plain data (arrays and plain objects by their entries). Nothing changes for a renderable that is set at spawn and never written. +**Breaking, and it still compiles: a renderable a system writes with a new value now destroys and makes its node again on every write.** Count your `create` calls after upgrading: a town whose houses, trees, and fields kept writing their renderables went from 142 nodes made in 5 minutes of play to 59,445, and every effect on those nodes restarted each time. + +**A `RenderSync` node follows its renderable.** A node used to be made once per entity id from the renderable's value at that time and only applied after, so an entity whose renderable a system changed (a relabel, an evolve) kept the node of its old value, and after a rewind an entity spawned under an id already drawn (an enemy where a predicted bullet was) was drawn with the other entity's node for its whole life. Now, when an entity's renderable value is no longer the one its node was made from, `RenderSync.system` destroys the node, calls `create` with the new value, and then `apply`. Writing an equal value keeps the node and runs no extra `apply`: values are compared as plain data (arrays and plain objects by their entries). Nothing changes for a renderable that is set at spawn and never written. What a game sees differently: a renderable written with a new value each tick now makes its node again each tick. Move data that changes while the node lives (an animation `frame`, a `tint`, a facing) out of the renderable into its own component, list it in `redrawOn`, and set it in `apply`: @@ -20,4 +22,17 @@ What a game sees differently: a renderable written with a new value each tick no }) ``` +Or key the sync on a component that never changes after spawn, and read the one systems write through a required `select` slot: + +```diff + RenderSync.system(Game, { +- renderable: House, ++ renderable: Tile, ++ select: { house: Game.Query.read(House) }, ++ redrawOn: [House], +- apply: (node, { renderable }) => node.update(renderable) ++ apply: (node, { data }) => node.update(data.house.get()) + }) +``` + A game that listed its renderable in `redrawOn` and wrote an `apply` redoing what `create` did can drop both: a new value makes the node again. diff --git a/.changeset/runtime-robustness.md b/.changeset/runtime-robustness.md index 36be316f..a1126861 100644 --- a/.changeset/runtime-robustness.md +++ b/.changeset/runtime-robustness.md @@ -8,5 +8,5 @@ Six runtime fixes: - **Storage no longer grows with every id ever given out.** Ids are never reused, so each 256 ids a component was ever stored on kept a small page for good: about 18 MB after 3 million spawned and despawned entities with 4 components, with 1,000 alive throughout. A page whose entities are all gone now leaves its store. Nothing changes for game code. - **A debug runtime catches a kept entity whose id is used again.** After a `restore`, or a rewind followed by a spawn, an entity kept from an earlier run (in a closure or `local`) could read and write the new entity that got its id. On a runtime made with `debug`, it now throws `... was used for an entity that was despawned`, like any despawned entity. Runtimes without `debug` are unchanged. The fix is the one the error names: keep `entity.id` and resolve it with `queries.x.get(id)` in each run. - **`runtime.tick` and `runtime.tryTick` throw when called while either is running.** A `tick` inside a system run by `tryTick` ran, and its commit cleared the outer system's rollback, so when the outer system then failed, its writes stayed. Both now throw `runtime.tick cannot run inside another tick` (or `runtime.tryTick ...`), from a system, a service, or a trace or host listener; so does `debug.beginFrame`, and `FrameCursor.next()` called while a step is running. Run the second schedule after the first returns: `runtime.tick(first); runtime.tick(second)`. -- **`nextMachines.x.reset()` from a transition schedule cancels a request queued before the marker.** When an `onEnter`, `onExit`, or `onTransition` system reset a machine whose request was queued before the current `applyStateTransitions(...)` and not applied yet, the request was applied anyway, while `getPending()` already reported `undefined`. It is now dropped. A `set` from a transition schedule still waits for the next marker, as before. +- **`nextMachines.x.cancelPending()` (named `reset()` before this release) from a transition schedule cancels a request queued before the marker.** When an `onEnter`, `onExit`, or `onTransition` system cancelled a machine's request, and that request was queued before the current `applyStateTransitions(...)` and not applied yet, the request was applied anyway, while `getPending()` already reported `undefined`. It is now dropped. A `set` from a transition schedule still waits for the next marker, as before. - **`FixedLoop`: `reset()` and `stop()` called from `update` apply to the frame in progress.** A `reset()` from `update` (after a level load) rendered the frame with `alpha: -1` and cost the next frame a step; it now ends the frame's stepping with `alpha` 0. A `stop()` from `update` let the frame's remaining catch-up updates and its `render` run; now nothing more runs. diff --git a/.changeset/saves-and-loads.md b/.changeset/saves-and-loads.md index 28752dcf..188b1996 100644 --- a/.changeset/saves-and-loads.md +++ b/.changeset/saves-and-loads.md @@ -6,7 +6,7 @@ A load leaves nothing of the world it replaced, and a save holds the work still Breaking: -- `runtime.restore(save)` gives every transient resource, and every resource and machine the save does not hold, the value the runtime was made with (`Game.Runtime.make({ resources, machines })`). Each used to keep its live value, so a load kept state of the replaced world: two peers that resync kept their own transient value and desynced again, and a player in `Over` who loaded a save from before that machine existed stayed in `Over`. A transient resource a capture system fills each tick (`InputCapture.system`) is filled again by its next run, so input needs nothing. The fix for any other transient resource, and for a resource the game added after its saves were written: set it again after a successful `restore` the way the game first set it, or give older saves the value with a `Snapshot.migration`. +- `runtime.restore(save)` gives every transient resource, and every resource and machine the save does not hold, the value the runtime was made with (`Game.Runtime.make({ resources, machines })`). Each used to keep its live value, so a load kept state of the replaced world: two peers that resync kept their own transient value and desynced again, and a player in `Over` who loaded a save from before that machine existed stayed in `Over`. A transient resource a capture system fills each tick (`InputCapture.system`) is filled again by its next run, so input needs nothing. The trap is a transient resource a system fills late in a step and another reads early in the next: in the first step after a load it reads the starting value (a town's stock read 0, so the first building after a load failed for lack of planks). `ritmo-devtools lint` notes it as `read-before-write` (info). Fill it in a system at the start of the schedule, or set it again after a successful `restore`; give a resource the game added after its saves were written its value with a `Snapshot.migration`. - `runtime.restore(save)` publishes nothing about the world it replaced. It used to report every entity of that world as despawned and each of its components as removed, so a system reading `readDespawned()` or `readRemoved(X)` saw entities that were never in the save, and saw them on one peer and not the other (seat 0 sets its world up before the first save). Now every system and inspector that read removed or despawned records, events, transition events, or relation failures before the load finds none from before it, and its next read reports `lagged()`; a reader whose first run comes after the load is not lagged. Restored entities still show as added. A system that mirrors entities (a node or a cache per entity) rebuilds from queries when its read is lagged: @@ -28,7 +28,7 @@ Breaking: +const save: Snapshot.WorldSnapshot = { version: 3, gameVersion: 0, nextEntity: 1, entities: [], relations: {}, resources: {}, machines: {}, nextStates: {}, scopes: {} } ``` - A world with nothing queued keeps the checksum it had, so replays recorded before still verify. `Snapshot.SnapshotError` adds `QueuedCommands` and `UnencodableNextState`, and `Snapshot.RestoreError` adds `InvalidNextState`: an exhaustive `switch` on either adds the cases. + Every save now has the field, `{}` when nothing is queued. A world with nothing queued keeps the checksum it had, so replays recorded before still verify, but code that compares saves or fingerprints them itself sees the new field: record those fixtures again. `Snapshot.SnapshotError` adds `QueuedCommands` and `UnencodableNextState`, and `Snapshot.RestoreError` adds `InvalidNextState`: an exhaustive `switch` on either adds the cases. Fixed: `runtime.checksum()` hashes what a JSON save writes. An object key whose value is `undefined` is skipped and `undefined` in an array hashes as `null`, so a world read back from its JSON save has the checksum of the world that wrote it; a schema with optional fields that accepted `{ a: 1, b: undefined }` made a peer that joined from a save report a desync against the host forever. diff --git a/.changeset/widened-entries-and-set-raw.md b/.changeset/widened-entries-and-set-raw.md new file mode 100644 index 00000000..0e3b4e9c --- /dev/null +++ b/.changeset/widened-entries-and-set-raw.md @@ -0,0 +1,7 @@ +--- +"@typeonce/ritmo": patch +--- + +Two messages name the fix. A `[Descriptor, value]` tuple built outside the call (returned by a helper, pushed into an array) widened to an array and failed with `` `${string} is not a component of this game's schema` ``, though the component is in it; it now fails with `this entry widened to an array: write the [Descriptor, value] tuple in the call, add `as const` where it is built, or annotate it Command.Entry`. And `set` on an entity state declared with `transitions` names `data.x.setRaw(entity, raw)` for data from outside the simulation (a test, a level file), next to `transition`. + +The `ritmo` skill's upgrade playbook lists what 0.23 changes without a compile error: `RenderSync` nodes made again on each new renderable value, a transient resource read before its writer after a load, `Clock.duration`'s half step, `FlowField` and `Collide2` moves, Ctrl/Cmd keys and non-standard gamepads, and the debug check of kept entities. diff --git a/packages/core/dtslint/CommandEntries.tst.ts b/packages/core/dtslint/CommandEntries.tst.ts index 446307c2..e1c4aaa0 100644 --- a/packages/core/dtslint/CommandEntries.tst.ts +++ b/packages/core/dtslint/CommandEntries.tst.ts @@ -1,4 +1,5 @@ import { Descriptor, Random, Schema } from "@typeonce/ritmo" +import type * as Command from "@typeonce/ritmo/Command" import * as Codecs from "../test/codecs.ts" import { describe, expect, it } from "tstyche" @@ -111,6 +112,22 @@ describe("an entry is checked against its own component", () => { Game.Command.spawn([Config, { speed: 1, debug: false }]) }) + it("says a tuple built outside the call widened, and accepts it as const or annotated", () => { + const widened = () => [[Hp, 1]] + // @ts-expect-error this entry widened to an array: write the [Descriptor, value] tuple in the call, add `as const` where it is built + Game.Command.spawn(...widened()) + const pushed = [Name, "n"] + // @ts-expect-error this entry widened to an array + Game.Command.spawn(pushed) + const constant = () => [[Hp, 1]] as const + expect(Game.Command.spawn(...constant())).type.not.toRaiseError() + const annotated = (hp: number): Command.Entry => [Hp, hp] + expect(Game.Command.spawn(annotated(1), [Name, "n"])).type.not.toRaiseError() + const many: Array | Command.Entry> = [] + many.push([Hp, 1], [Name, "n"]) + expect(Game.Command.spawn(...many)).type.not.toRaiseError() + }) + it("checks inserts the same way", () => { Game.System("Entries/Insert", { queries: { positions: Positions }, diff --git a/packages/core/dtslint/EntityState.tst.ts b/packages/core/dtslint/EntityState.tst.ts index 0a65600b..c2d98309 100644 --- a/packages/core/dtslint/EntityState.tst.ts +++ b/packages/core/dtslint/EntityState.tst.ts @@ -125,7 +125,7 @@ describe("Descriptor.EntityState", () => { // @ts-expect-error Object literal may only specify known properties, and 'm' does not exist in type data.mode.set(entity, { _tag: "b", n: 1, m: 1 }) // With one, no set gets around it. - // @ts-expect-error this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state + // @ts-expect-error this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state, or write data from outside the simulation (a test, a level file) with data.x.setRaw(entity, raw) data.contact.set(entity, { _tag: "ground", platform: null }) // @ts-expect-error this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state data.phase.set(entity, { _tag: "ready" }) diff --git a/packages/core/skills/ritmo/playbooks/state-and-upgrades.md b/packages/core/skills/ritmo/playbooks/state-and-upgrades.md index 450f61a2..21c3c0fe 100644 --- a/packages/core/skills/ritmo/playbooks/state-and-upgrades.md +++ b/packages/core/skills/ritmo/playbooks/state-and-upgrades.md @@ -20,6 +20,12 @@ - 0.15: the devtools monitor and its "Save replay" button are gone: bind `recorder.save(reason)` to a key in the host. - 0.23: `restore` leaves nothing of the world it replaced. A transient resource, and a resource or machine the save lacks, takes the value the runtime was made with instead of keeping its own: set again after `restore` what the game kept in one. No despawn or removal of the replaced world is reported: a system that mirrors entities (a node, a cache per entity) rebuilds from queries when its read reports `lagged()`. - 0.23: `runtime.snapshot()` and `runtime.checksum()` fail with `QueuedCommands` while a command waits for a marker: end the schedule the save follows with `Game.Schedule.applyDeferred()`. + - 0.23: `RenderSync` makes a node again whenever a system writes its renderable with a new value: count `create` calls, and key a sync whose renderable systems write on a component that never changes, reading the written one through a required `select` slot. + - 0.23: a transient resource filled late in a step and read early in the next reads its starting value in the first step after `restore`: fill it at the start of the schedule (`ritmo-devtools lint` notes it as `read-before-write`). + - 0.23: `Clock.duration` rounds a half step up: such a duration is one step longer, and replays recorded with it diverge. + - 0.23: `FlowField` gives a way out of blocked cells, and `Collide2.moveCircle` / `pushOut` no longer pass through walls closing in: AI and balance numbers move. Find the change that moved a baseline before recording it again. + - 0.23: a key pressed with Ctrl or Cmd presses no action unless the bindings hold `"Control"` or `"Meta"`; a gamepad without the standard mapping reads its buttons only (`standard: false`). + - 0.23: a debug runtime throws for an entity kept from an earlier run that no longer matches the system's queries. - Any version: a new lint rule is off until `.oxlintrc.json` lists it. Compare the config with the `@typeonce/oxlint-plugin-ritmo` README after every upgrade. **Check:** a test takes a snapshot, round-trips it through `JSON.stringify` and `JSON.parse`, restores it, and asserts each new value; a snapshot taken before the change restores after it; the game's check passes. diff --git a/packages/core/src/Command.ts b/packages/core/src/Command.ts index 892b76aa..e28e02ed 100644 --- a/packages/core/src/Command.ts +++ b/packages/core/src/Command.ts @@ -114,6 +114,16 @@ export namespace Draft { * it. Each tuple is checked against its own descriptor * ({@link ExactEntries}). `Game.Command.entryRaw(...)` returns one in a * `Result` for raw input. + * + * A tuple built outside the call (returned by a helper, pushed into an + * array) widens to an array unless it is `as const` or annotated with this + * type, and no longer reads as an entry: + * + * @example + * ```ts + * const push = (x: number): Command.Entry => [Velocity, { x, y: 0 }] + * const draft = Game.Command.spawn([Position, { x: 0, y: 0 }], push(2)) + * ``` */ export type Entry> = readonly [D, ReadonlyValue>] @@ -162,6 +172,8 @@ export type UncheckedEntry = readonly [{ readonly kind: string; readonly name: s * * - `Game/Position has no field z: pass only its own fields` * - `Game/Outside is not a component of this game's schema: ...` + * - `this entry widened to an array: ...`, for a tuple built outside the + * call without `as const` or a {@link Entry} annotation * - a missing field or a value of another type: TypeScript's message * against the component's own value */ @@ -184,8 +196,15 @@ type FieldKeys = { type CheckedEntries> = { readonly [K in keyof Entries]: CheckedEntry } -type NotInSchema = - `${Names} is not a component of this game's schema: spawn and insert take components registered in Schema.fragment({ components: { ... } })` +// A tuple built outside the call (returned by a helper, pushed into an array) widens to an +// array, fails the entries' constraint, and is checked as the constraint's `UncheckedEntry`, +// whose name is `string`: that is the widening, not a component missing from the schema. +type NotInSchema = string extends Names ? WidenedEntry + : `${Names} is not a component of this game's schema: spawn and insert take components registered in Schema.fragment({ components: { ... } })` + +type WidenedEntry = + "this entry widened to an array: write the [Descriptor, value] tuple in the call, add `as const` where it is built, or annotate it Command.Entry" + /** Names the component in the message for a value that does not fit it: `{ readonly x: number; readonly y: number; } & ValueOf<"Game/Position">`. */ interface ValueOf { readonly "~component"?: Component } diff --git a/packages/core/src/Query.ts b/packages/core/src/Query.ts index 1ff92a56..5bccacde 100644 --- a/packages/core/src/Query.ts +++ b/packages/core/src/Query.ts @@ -816,7 +816,7 @@ export interface ConstructedWriteSlot extends WriteSlot export type UncheckedEntityStateWrite = { readonly [State in StateName]: ReadonlyArray> } extends Transitions ? (entity: EntityProving, value: ReadonlyValue>>) => void - : { readonly __fixMove__: "this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state" } + : { readonly __fixMove__: "this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state, or write data from outside the simulation (a test, a level file) with data.x.setRaw(entity, raw)" } /** * Write slot of an entity state component (`Descriptor.EntityState`). @@ -874,12 +874,13 @@ export interface EntityStateWriteSlot ): Result.Result>> /** - * Writes any value without checking the current state: a reset, or the - * value a pure function computed from the current one. Use `transition` - * and `update` for the moves a system makes itself, so the current state - * is checked. On an entity state declared with `transitions` it does not - * compile: `transition` is the way to move it, so a system's moves are - * ones the graph lists. + * Writes any value without checking the current state: the value a pure + * function computed from the current one. Use `transition` and `update` + * for the moves a system makes itself, so the current state is checked. + * On an entity state declared with `transitions` it does not compile: + * `transition` is the way to move it, so a system's moves are ones the + * graph lists. To write a state there from outside the simulation (a + * test, a level file), use `setRaw`, which decodes it first. */ readonly set: UncheckedEntityStateWrite /**