From 4d9547340a123135035ef369d9b52e83974245d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:03:31 +0000 Subject: [PATCH] Version Packages --- .changeset/browser-gamepads-capture-keys.md | 23 -- .changeset/checkpoint-added-again.md | 5 - .changeset/checkpoint-graves.md | 5 - .changeset/clock-steps-raw.md | 15 - .changeset/descendants-wide.md | 5 - .changeset/devtools-next-states.md | 5 - .changeset/entity-constructors-internal.md | 19 - .changeset/exact-entries-browser.md | 5 - .changeset/exact-entries.md | 43 -- .changeset/exact-updates.md | 16 - .changeset/faster-debug-freeze.md | 9 - .changeset/fix-messages-devtools.md | 5 - .changeset/fix-messages-runtime.md | 23 -- .changeset/fix-messages.md | 18 - .changeset/float-timers-step-names.md | 12 - .changeset/fuzz-starts-from-replay.md | 5 - .changeset/global-access.md | 13 - .changeset/infinity-stored-data.md | 15 - .changeset/kept-entities-checked.md | 21 - .changeset/kept-query-values-copies.md | 12 - .changeset/keyboard-shortcuts.md | 16 - .changeset/lint-every-skill.md | 5 - .changeset/lint-mismatched-versions.md | 10 - .changeset/literal-descriptor-names.md | 18 - .changeset/machine-transitions-lint.md | 5 - .changeset/machine-transitions.md | 45 --- .../math-negative-limits-and-distance.md | 9 - .changeset/math-walls-closing-in.md | 11 - .changeset/name-the-schema.md | 17 - .changeset/netcode-events-lost-resync.md | 12 - .changeset/netcode-local-input-schema.md | 14 - .changeset/netcode-playbook-restarted.md | 5 - .changeset/netcode-queued-commands.md | 5 - .changeset/netcode-receiver-bounds.md | 5 - .changeset/netcode-restarted-seats.md | 28 -- .changeset/no-kept-query-values.md | 9 - .changeset/one-name-per-lookup.md | 15 - .changeset/oxlint-readme-snapshot.md | 5 - .changeset/pause-while-devtools.md | 5 - .changeset/pause-while.md | 17 - .changeset/performance-playbook.md | 9 - .changeset/plugin-readme-random.md | 5 - .changeset/pointer-chords.md | 5 - .changeset/query-rows.md | 23 -- .changeset/random-wide-spans.md | 9 - .changeset/render-node-follows-renderable.md | 38 -- .changeset/render-related-lookup.md | 5 - .changeset/render-sync-restore.md | 5 - .changeset/replay-window-not-compared.md | 14 - .changeset/rewind-in-devtools.md | 10 - .changeset/rewind-readers.md | 11 - .changeset/rewind-trace-event.md | 16 - .changeset/root-mandatory-browser.md | 5 - .changeset/root-mandatory-render.md | 5 - .changeset/root-mandatory.md | 35 -- .changeset/runtime-result-callbacks.md | 11 - .changeset/runtime-robustness.md | 12 - .changeset/saves-and-loads.md | 35 -- .changeset/setup-root.md | 5 - .changeset/state-spread-known-states.md | 15 - .changeset/system-failure-union.md | 15 - .changeset/tools-fx-approval-and-signals.md | 9 - .changeset/tools-review-safety.md | 22 - .changeset/transition-requests.md | 17 - .changeset/widened-entries-and-set-raw.md | 7 - packages/browser/CHANGELOG.md | 75 ++++ packages/browser/package.json | 2 +- packages/core/CHANGELOG.md | 380 ++++++++++++++++++ packages/core/package.json | 2 +- packages/devtools/CHANGELOG.md | 74 ++++ packages/devtools/package.json | 2 +- packages/devtools/src/internal/version.ts | 2 +- packages/math/CHANGELOG.md | 47 +++ packages/math/package.json | 2 +- packages/netcode/CHANGELOG.md | 82 ++++ packages/netcode/package.json | 2 +- packages/oxlint-plugin/CHANGELOG.md | 70 ++++ packages/oxlint-plugin/package.json | 2 +- packages/pixi/CHANGELOG.md | 10 + packages/pixi/package.json | 2 +- packages/render/CHANGELOG.md | 75 ++++ packages/render/package.json | 2 +- packages/tools/CHANGELOG.md | 28 ++ packages/tools/package.json | 2 +- packages/tools/src/internal/version.ts | 2 +- 85 files changed, 852 insertions(+), 884 deletions(-) delete mode 100644 .changeset/browser-gamepads-capture-keys.md delete mode 100644 .changeset/checkpoint-added-again.md delete mode 100644 .changeset/checkpoint-graves.md delete mode 100644 .changeset/clock-steps-raw.md delete mode 100644 .changeset/descendants-wide.md delete mode 100644 .changeset/devtools-next-states.md delete mode 100644 .changeset/entity-constructors-internal.md delete mode 100644 .changeset/exact-entries-browser.md delete mode 100644 .changeset/exact-entries.md delete mode 100644 .changeset/exact-updates.md delete mode 100644 .changeset/faster-debug-freeze.md delete mode 100644 .changeset/fix-messages-devtools.md delete mode 100644 .changeset/fix-messages-runtime.md delete mode 100644 .changeset/fix-messages.md delete mode 100644 .changeset/float-timers-step-names.md delete mode 100644 .changeset/fuzz-starts-from-replay.md delete mode 100644 .changeset/global-access.md delete mode 100644 .changeset/infinity-stored-data.md delete mode 100644 .changeset/kept-entities-checked.md delete mode 100644 .changeset/kept-query-values-copies.md delete mode 100644 .changeset/keyboard-shortcuts.md delete mode 100644 .changeset/lint-every-skill.md delete mode 100644 .changeset/lint-mismatched-versions.md delete mode 100644 .changeset/literal-descriptor-names.md delete mode 100644 .changeset/machine-transitions-lint.md delete mode 100644 .changeset/machine-transitions.md delete mode 100644 .changeset/math-negative-limits-and-distance.md delete mode 100644 .changeset/math-walls-closing-in.md delete mode 100644 .changeset/name-the-schema.md delete mode 100644 .changeset/netcode-events-lost-resync.md delete mode 100644 .changeset/netcode-local-input-schema.md delete mode 100644 .changeset/netcode-playbook-restarted.md delete mode 100644 .changeset/netcode-queued-commands.md delete mode 100644 .changeset/netcode-receiver-bounds.md delete mode 100644 .changeset/netcode-restarted-seats.md delete mode 100644 .changeset/no-kept-query-values.md delete mode 100644 .changeset/one-name-per-lookup.md delete mode 100644 .changeset/oxlint-readme-snapshot.md delete mode 100644 .changeset/pause-while-devtools.md delete mode 100644 .changeset/pause-while.md delete mode 100644 .changeset/performance-playbook.md delete mode 100644 .changeset/plugin-readme-random.md delete mode 100644 .changeset/pointer-chords.md delete mode 100644 .changeset/query-rows.md delete mode 100644 .changeset/random-wide-spans.md delete mode 100644 .changeset/render-node-follows-renderable.md delete mode 100644 .changeset/render-related-lookup.md delete mode 100644 .changeset/render-sync-restore.md delete mode 100644 .changeset/replay-window-not-compared.md delete mode 100644 .changeset/rewind-in-devtools.md delete mode 100644 .changeset/rewind-readers.md delete mode 100644 .changeset/rewind-trace-event.md delete mode 100644 .changeset/root-mandatory-browser.md delete mode 100644 .changeset/root-mandatory-render.md delete mode 100644 .changeset/root-mandatory.md delete mode 100644 .changeset/runtime-result-callbacks.md delete mode 100644 .changeset/runtime-robustness.md delete mode 100644 .changeset/saves-and-loads.md delete mode 100644 .changeset/setup-root.md delete mode 100644 .changeset/state-spread-known-states.md delete mode 100644 .changeset/system-failure-union.md delete mode 100644 .changeset/tools-fx-approval-and-signals.md delete mode 100644 .changeset/tools-review-safety.md delete mode 100644 .changeset/transition-requests.md delete mode 100644 .changeset/widened-entries-and-set-raw.md diff --git a/.changeset/browser-gamepads-capture-keys.md b/.changeset/browser-gamepads-capture-keys.md deleted file mode 100644 index 317b495f..00000000 --- a/.changeset/browser-gamepads-capture-keys.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@typeonce/ritmo-browser": minor ---- - -**Breaking: a gamepad without the standard mapping is read by its buttons only.** Its axes cannot be mapped (many such pads report their triggers as axes resting at -1), so before, nobody touching an Xbox 360 pad on such a browser read as `lastUsed: "gamepad"` with the right stick pinned left, and `join: "any"` seated it by itself. For a pad whose `mapping` is not `"standard"`, the sticks and analog triggers now read as rest (`left`/`right` `{ x: 0, y: 0 }`, vector actions 0, `leftTrigger`/`rightTrigger` 0) and its axes never count toward `used`, `lastUsed`, or a join. Its buttons are read by index as before, a press still counts as use, and an action bound to `"rightTrigger"` follows the button at that index. The snapshot gains `standard: boolean`, `false` for such a pad (`true` for a standard pad, with no pad, and for `scripted` pads), so a game can tell the player the sticks are not supported and offer button bindings: - -```ts -const pad = input.snapshot().devices.gamepad -if (pad.connected && !pad.standard) showNotice("This controller's sticks are not supported: bind its buttons in Controls") -``` - -A `Gamepad.Snapshot` built by hand needs the new field; `Gamepad.idle(bindings)` has it: - -```diff --const neutral = { ...rest, used: false, connected: false, id: null } -+const neutral = { ...rest, used: false, standard: true, connected: false, id: null } -``` - -`Pointer.actions` releases the pointer capture whenever it lets go of every button: a window blur, a `pointercancel`, leaving the pointer lock, and `dispose()` now call `releasePointerCapture` for the pointer the first press captured, where before only the last button's `pointerup` did. A pointer the browser no longer captures (after a cancel) is skipped instead of throwing. - -`Keyboard.actions` treats keys that type the same character as one key for a character binding. `run: ["Shift"]` stays held while either Shift key is down, with one press when the first goes down and one release when the last comes up; the same holds for both Ctrl and Alt keys and for a digit on the row and on the numpad. Before, letting go of left Shift released `run` while right Shift was still down. Bind `Keyboard.code("ShiftLeft")` to tell them apart. A host that sends no `code` behaves as before. - -A new press of a key whose keyup the page never saw (and no blur or Cmd release cleared) now matches its release by the character it types now, and lets go of the one it typed before. Before, W pressed again as "∑" with Option held kept the old "w" for its release, so an action bound to "∑" stayed held until a blur. Repeats are unchanged: a repeat presses a key only if it is neither held nor known to be down. diff --git a/.changeset/checkpoint-added-again.md b/.changeset/checkpoint-added-again.md deleted file mode 100644 index 3a6d17ff..00000000 --- a/.changeset/checkpoint-added-again.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -The `Checkpoint` module doc names the one case where a reader outside the rewound schedule sees a component as `added` again (it saw the component, did not run while a later tick removed it, and a rewind brought it back), and what a host does about it: key what it creates per entity by entity id. diff --git a/.changeset/checkpoint-graves.md b/.changeset/checkpoint-graves.md deleted file mode 100644 index a527e3e8..00000000 --- a/.changeset/checkpoint-graves.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -A held checkpoint keeps only the events, removals, and despawns a rewind can bring back: those published before the newest held checkpoint was taken. A game that took one checkpoint and kept it (a level retry, an undo) kept every event batch and removal record published after it: about 160 MB after 2,000 ticks of one 10,000-number event, now about 0.5 MB. No API change. diff --git a/.changeset/clock-steps-raw.md b/.changeset/clock-steps-raw.md deleted file mode 100644 index 678d6430..00000000 --- a/.changeset/clock-steps-raw.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: `Clock.durationOfSteps` takes a literal count only; a count from data goes through the new `Clock.durationOfStepsRaw`.** A computed count (a `number`) compiled and threw at runtime for 0, a negative, or a fraction ("Clock.durationOfSteps needs a positive whole number of steps, got 0"). It is now a compile error naming the fix (`Clock.durationOfSteps needs a literal count of steps: use Clock.durationOfStepsRaw for data`), as `Clock.duration` already did for seconds, and `Clock.durationOfStepsRaw(step, n)` returns a `Result` that fails with `InvalidDuration`, the error `Clock.durationRaw` returns (its `seconds` is the count divided by the rate): - -```diff --const reload = Clock.durationOfSteps(step, config.reloadSteps) -+const reload = Clock.durationOfStepsRaw(step, config.reloadSteps) -+if (!reload.ok) return reload -``` - -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. Replays and fuzz cases recorded with one of these durations diverge: record them again. diff --git a/.changeset/descendants-wide.md b/.changeset/descendants-wide.md deleted file mode 100644 index f4b6ac6d..00000000 --- a/.changeset/descendants-wide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -`lookup.descendants(target, relation, { order: "breadth" })` (and a query's `descendants`) overflowed the stack below a parent with more than about 125,000 children; it now lists them. diff --git a/.changeset/devtools-next-states.md b/.changeset/devtools-next-states.md deleted file mode 100644 index bf5bd8ac..00000000 --- a/.changeset/devtools-next-states.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-devtools": patch ---- - -`Desync.compare` lists the queued next states that differ between two saves (`next state Game/Round`), now that saves hold them. `Fuzz`'s `Unreplayable` and the recorder's "no tick could be saved" error name queued commands as a cause next to a rejected `"~encode"`. No change for games. diff --git a/.changeset/entity-constructors-internal.md b/.changeset/entity-constructors-internal.md deleted file mode 100644 index 44812cd7..00000000 --- a/.changeset/entity-constructors-internal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: `Entity.makeEntityId`, `Entity.makeHandle`, and `Entity.draft` are no longer exported.** They were the runtime's own constructors and checked nothing: `commands.spawn(Entity.draft(Entity.makeEntityId(7), { "Game/Position": { x: 1, y: 2 } }))` compiled under `spawns(Position)` and spawned an entity with no components while its type said it held `Position`. Use the checked ways instead: - -```diff --commands.spawn(Entity.draft(Entity.makeEntityId(7), { "Game/Position": { x: 1, y: 2 } })) -+commands.spawn(Game.Command.spawn([Position, { x: 1, y: 2 }])) - --const handle = Entity.makeHandle(entity.id) -+const handle = Game.Entity.handle(entity) - --// a number read from a save or a network message --const id = Entity.makeEntityId(raw) -+if (Entity.isHandle(Root)(raw)) { -+ const id = Game.Entity.id(raw) // still proves nothing: resolve it with queries.x.get(id) -+} -``` diff --git a/.changeset/exact-entries-browser.md b/.changeset/exact-entries-browser.md deleted file mode 100644 index c0d34502..00000000 --- a/.changeset/exact-entries-browser.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-browser": patch ---- - -The `BrowserTickSource` example writes its spawn entry as a `[Descriptor, value]` tuple, since core removed `Game.Command.entry`. diff --git a/.changeset/exact-entries.md b/.changeset/exact-entries.md deleted file mode 100644 index 3b1e053b..00000000 --- a/.changeset/exact-entries.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: a `[Descriptor, value]` entry is checked against its own component, and `Game.Command.entry` is removed.** A tuple entry of `Game.Command.spawn(...)`, `Game.Command.extend(...)`, or `commands.insert(...)` has an inferred value, so TypeScript's excess property check never reached it: `commands.insert(entity, [Position, { x: 0, y: 0, zzz: 1 }])` compiled and stored `zzz`. Its other errors were checked against every component of the schema at once and named an unrelated one (`'"Game/Health"' is not assignable to type '"Game/Velocity"'`). Each entry is now checked on its own, and the error is on that entry: - -| Entry | Message now | -| --- | --- | -| `[Position, { x: 1, y: 2, z: 3 }]` | `Type '{ x: number; y: number; z: number; }' is not assignable to type '"Game/Position has no field z: pass only its own fields"'` | -| `[Position, { x: 1 }]` | `Type '{ x: number; }' is not assignable to type '{ readonly x: number; readonly y: number; } & ValueOf<"Game/Position">'` / `Property 'y' is missing ...` | -| `[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 --commands.insert(entity, Game.Command.entry(Kind, "pickup")) -+commands.insert(entity, [Kind, "pickup"]) - Game.Command.spawn( - Game.Command.entryRaw(Position, { x, y }), -- Game.Command.entry(Wall, {}) -+ [Wall, {}] - ) -``` - -**Breaking: an initial resource in `Game.Runtime.make({ resources })` with a field the resource does not have no longer compiles.** `resources: { Config: { speed: 1, bogus: 2 } }` compiled and stored `bogus`, which then failed to restore from a save against a strict schema. It now fails with `Game/Config has no field bogus: pass only its own fields` (for a constructed resource, against its raw input). Drop the field. - -```diff --Game.Runtime.make({ services, resources: { Config: { speed: 1, bogus: 2 } } }) -+Game.Runtime.make({ services, resources: { Config: { speed: 1 } } }) -``` diff --git a/.changeset/exact-updates.md b/.changeset/exact-updates.md deleted file mode 100644 index d4d32576..00000000 --- a/.changeset/exact-updates.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: an `update` callback that returns a field the value does not have no longer compiles, spread or not.** A spread in a callback's result escapes TypeScript's excess property check, so `data.position.update(entity, (p) => ({ ...p, xx: p.x + 1 }))` compiled, stored `xx` next to `x` and `y`, and a save holding it then failed to restore against a strict schema (`InvalidComponent ... Unrecognized key: "xx"`). Component and resource `update`, and `updateRaw` on constructed descriptors, now fail with a message naming the descriptor and the field: - -``` -Game/Position has no field xx: return only its own fields -``` - -Fix the field name, or drop the field. Values with an index signature (`Record`), arrays, tuples, primitives, `Map`s, `Set`s, and unions of object shapes (any field of any member) are accepted as before. `WriteCell` and `ConstructedWriteCell` take the resource's name as a last, optional type parameter. A helper generic over the value type (`(slot: WriteSlot) => slot.update(entity, (v) => v)`) cannot be checked and no longer compiles: write the result there with `slot.set(entity, next(slot.get(entity)))`. - -```diff --data.position.update(entity, (p) => ({ ...p, xx: p.x + 1 })) -+data.position.update(entity, (p) => ({ ...p, x: p.x + 1 })) -``` diff --git a/.changeset/faster-debug-freeze.md b/.changeset/faster-debug-freeze.md deleted file mode 100644 index 37ce0d51..00000000 --- a/.changeset/faster-debug-freeze.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@typeonce/ritmo": patch -"@typeonce/ritmo-browser": patch -"@typeonce/ritmo-devtools": patch ---- - -A debug runtime freezes what it is handed about twice as fast. The freeze walks arrays by index and objects by their enumerable keys and own symbols, and no longer calls `Reflect.ownKeys` on every value: in a top-down shooter's crowd scene a debug step went from 0.40 ms to 0.25 ms (0.18 ms without `debug`), and in a stress scene of 2000 projectiles from 13.5 ms to 7.5 ms. What is frozen is unchanged for plain data; an own non-enumerable property is no longer walked. - -The docs say what `debug` costs: it freezes every value written to the world, with or without a listener. The `BrowserHostClock` example attaches the handle in development only (`import.meta.env.DEV ? { hostClock } : undefined`), and `Debug.Option` notes that the clock times trace events only, so a headless runtime shared by tests and long bot runs takes `debug` as a choice. diff --git a/.changeset/fix-messages-devtools.md b/.changeset/fix-messages-devtools.md deleted file mode 100644 index 657673a7..00000000 --- a/.changeset/fix-messages-devtools.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-devtools": patch ---- - -An invariant or `labels` inspector that reads a resource, service, or machine the runtime does not provide fails with the same message as `runtime.tick`, naming every one and where to provide it: `the runtime is missing resource Game/Limit: add to Game.Runtime.make({ resources: { Key: value } })`. It used to be a cut union under `__fixInvariantRequirements__` or `__fixLabelsRequirements__`. diff --git a/.changeset/fix-messages-runtime.md b/.changeset/fix-messages-runtime.md deleted file mode 100644 index d6235e95..00000000 --- a/.changeset/fix-messages-runtime.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -Four more mistakes now fail with a message that names the cause and the fix. Programs that compiled before still compile. - -| Mistake | Message now | -| --- | --- | -| An entity where an id goes: `queries.x.get(entity)`, `queries.x.children`/`descendants`, `lookup.related(entity, R)` (and every `lookup` method), `commands.relate(entity, R, target)`, `unrelate`, `reorderChildren` | `this takes an entity id, not an entity: pass entity.id` | -| A transition schedule (`Game.Schedule.onEnter`, `onExit`, `onTransition`) passed to `runtime.tick`, `tryTick`, `checkpoint`, `debug.beginFrame`, or `Game.Schedule(...)` | `onEnter, onExit, and onTransition schedules run when their machine changes state: bundle them with Game.Schedule.transitions(...) and pass that to Game.Schedule.applyStateTransitions(...) in a schedule you tick` | -| A schedule needing resources, services, or machines the runtime was not made with, at `runtime.tick` (and `runtime.inspect`, `Netcode`'s `simulate`, devtools invariants and labels) | `the runtime is missing resource Game/Score, service Game/Audio, machine Game/Flow: add to Game.Runtime.make({ resources: { Key: value }, services: Game.Runtime.services(Game.Runtime.service(Descriptor, implementation)), machines: Game.Runtime.machines(Game.Runtime.machine(Machine, initial)) })`, every one named, with the fix for each kind that is missing | -| `services: {}` (or a hand-written object) in `Game.Runtime.make` | `services are built with Game.Runtime.services(Game.Runtime.service(Descriptor, implementation)), or Game.Runtime.services() when there are none` | -| `machines: {}` in `Game.Runtime.make` | `machines are started with Game.Runtime.machines(Game.Runtime.machine(Machine, initial)), or left out when there are none` | - -`insert`, `remove`, and `despawn` still take the entity, since they check what it proves. The id parameters have the new type `Entity.IdParameter`: an id, plus a member no value has, which carries the message. - -The requirements message used to be a union of `{ __runtimeRequirementError__, __requirement__ }` objects under `__fixRuntimeRequirements__`, which the compiler cut after two or three. It is now the name of the one property that `Runtime.ValidateScheduleArray` and `Runtime.ValidateInspector` add, since the compiler prints a missing property's name whole. A package that read `__fixRuntimeRequirements__` out of either type intersects with the type itself instead: - -```diff --type Errors = Runtime.ValidateInspector extends { readonly __fixRuntimeRequirements__: infer E } ? E : never --export type Validate = [Errors] extends [never] ? unknown : { readonly __fixMine__: Errors } -+export type Validate = Runtime.ValidateInspector -``` diff --git a/.changeset/fix-messages.md b/.changeset/fix-messages.md deleted file mode 100644 index 64f2b545..00000000 --- a/.changeset/fix-messages.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -Eight common mistakes now fail with a message that names the cause and the fix, instead of a bare type mismatch. Programs that compiled before still compile. - -| Mistake | Message now | -| --- | --- | -| `({ commands }) => commands.spawn(draft)` or `async () => {}` as a system body | `a system returns nothing or Result.failure(...), and runs synchronously: wrap an expression body in braces, and drop async` | -| `Schema.fragment({ relations: { ChildOf } })` with the pair `Descriptor.Hierarchy(...)` returned | `ChildOf is the pair Descriptor.Hierarchy(...) returns: register its relation, ChildOf.relation` | -| `Game.System.relates(Targets)` with the pair | `Game/Targets is passed as the pair Descriptor.Hierarchy/Relation(...) returns: pass the relation itself, the pair's .relation` | -| `querys: {}` next to other keys of a system spec | `querys is not a system spec key: use queries, resources, events, ...` (every category) | -| `Game.Query.write(...)` in an inspector's query | `an inspector only reads: select with Game.Query.read (or optional) instead of Game.Query.write` | -| An entity of another query in a slot of `data` | `Property '"proves component Game/Position"' is missing in type '{ readonly "proves component Game/Health": true; }'` | -| `Schema.fragment({ components: [Position] })` | `components takes a record keyed by name, not an array: components: { Position, Health }` | -| `Descriptor.Component("Game/Name")` (and `Resource`, `Event`, `Service`, `TransientComponent`, `TransientResource`) | `the name goes in the second call: Descriptor.Component()(name)` | - -`Game.System` takes its body as `System.SystemBody` (a `SystemRun` whose return type also carries the message); a system's `run` is still a `SystemRun`. diff --git a/.changeset/float-timers-step-names.md b/.changeset/float-timers-step-names.md deleted file mode 100644 index 4ff5e479..00000000 --- a/.changeset/float-timers-step-names.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -`ritmo/no-float-timers` knows more names and shapes of a step's length, and reports `x - dt` only where it is written back, so it counts across ticks. - -Newly reported, with the rule's message saying to store a `Clock.deadline` and check `Clock.reached`: - -- other names: `x -= fixedDt`, `x -= dtSeconds`, `x -= STEP_SECONDS` (any case, with or without `_`), `x -= time.delta`, `x = x - step.seconds`, and a bare `x -= delta`; -- other shapes: `x -= 1 / 60`, `x -= dt * 1`, `elapsedMs += dt * 1000`, `x -= Math.min(dt, 0.1)`, and a `const` that holds a step's length (`const tick = frame.dt; timer -= tick`). - -No longer reported: `x - dt` / `x + dt` whose value is not written back where `x` came from, such as a look-ahead (`const until = horizon + dt`) or a test (`if (accumulator - dt >= 0)`). Written back means a target of the same name (`x = x - dt`, `let cooldown = Math.max(0, state.cooldown - dt)`, `{ left: buff.left - dt }`, `resources.timer.set(timer - dt)`) or an `update` callback's result (`resources.timer.update((left) => left - dt)`); those are still reported. diff --git a/.changeset/fuzz-starts-from-replay.md b/.changeset/fuzz-starts-from-replay.md deleted file mode 100644 index 96bd0f5d..00000000 --- a/.changeset/fuzz-starts-from-replay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-devtools": patch ---- - -`Fuzz.run` finds only bugs its replay reproduces. A run used to tick `setup` and carry on with the live world, while its replay starts from a save of that world, so a bug that needed what setup left in flight (an event emitted in setup and read on the first frame) came back with `reproduces: false` and a replay that passed. Each run now loads its own save after `setup`, before the first fuzzed frame, so the run and its replay start from the same world. A game whose setup emits events for the first frame no longer has them in fuzz runs, as it never had them in the replays. diff --git a/.changeset/global-access.md b/.changeset/global-access.md deleted file mode 100644 index 5c2973f2..00000000 --- a/.changeset/global-access.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -`ritmo/use-clock-for-time`, `ritmo/use-random-for-randomness`, `ritmo/use-ops-for-math`, and `ritmo/keep-simulation-headless` find a global however the file reaches it, so simulation code that passed before can now be reported. Newly reported, with the rule's existing message saying what to write instead: - -- through a global object: `globalThis.Math.random()`, `globalThis.crypto.getRandomValues(bytes)`, `globalThis.Date.now()`, `globalThis.performance.now()`, `globalThis.setTimeout(...)`, `self.Math.sin(x)` (fixed to `Ops.sin(x)` like `Math.sin(x)`), `globalThis.localStorage`, `self.innerWidth`; -- through a `const` that holds the global: `const roll = Math; roll.random()`, `const wait = setTimeout; wait(f, 10)`, `const D = Date; new D()`; -- destructured: `const { random } = Math`, `const { randomUUID } = crypto`, `const { now } = Date`, `const { now } = performance`, `const { localStorage } = globalThis`, `const { Math: M } = globalThis` (an alias of Math, like `const M = Math`); -- by `use-clock-for-time`, globals it did not list: `setImmediate(...)`, `process.hrtime()` and `process.hrtime.bigint()`, `performance.timeOrigin`, `performance.mark(...)`, `Temporal.Now`, `Date()` called without `new`, and `getTimezoneOffset()`; -- by `keep-simulation-headless`, sound: `new Audio(...)` and `new AudioContext()`. Publish what happened as an event and play the sound from a render or host system that reads it (`Game.System.readEvent(Hit)`) and requests the game's audio as a service (`Game.System.service(Audio)`); under a netcode session, from `session.events(Hit)`. - -Locals, parameters, and imports with these names, fields of other objects (`config.Date.now()`), and types are still not reported. Host code inside a simulation folder that reads one on purpose takes a disable comment with its reason: `// oxlint-disable-next-line ritmo/use-clock-for-time -- this test is the host`. diff --git a/.changeset/infinity-stored-data.md b/.changeset/infinity-stored-data.md deleted file mode 100644 index 6e5e9911..00000000 --- a/.changeset/infinity-stored-data.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -`ritmo/no-infinity-in-simulation-data` reports Infinity and NaN in more of the data a world stores, and no longer reports local scratch. - -Newly reported, with the message saying to use `null` and a nullable number schema: - -- `1 / 0` and `Math.min()` / `Math.max()` of nothing: `{ since: 1 / 0 }`, `{ since: Math.min() }`; -- an element of an array literal: `data.range.set(entity, [0, Infinity])`; -- a call given one, whose result is stored: `{ dash: make(Infinity) }` (`Math.*` and `Number.*` calls are not reported); -- a `const` declared as one: `const LIMIT = Infinity`, then `{ max: LIMIT }`; -- a search seed stored unchecked: `{ distance: best }` after `let best = Infinity`. Store it checked: `{ distance: Number.isFinite(best) ? best : null }`. A comparison with `Infinity` or a `Number.isFinite(best)` anywhere in the file counts as the check. - -No longer reported: a literal search seed in a `let` (`let nearest = { distance: Infinity, id: -1 }`), a fallback in a function whose fields are only compared (`const hit = sweep ?? { time: Infinity }; if (hit.time < limit) ...`), and values a `for...of` tries (`for (const size of [0, Infinity])`). diff --git a/.changeset/kept-entities-checked.md b/.changeset/kept-entities-checked.md deleted file mode 100644 index ee90f2eb..00000000 --- a/.changeset/kept-entities-checked.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking (debug runtimes only): a debug runtime checks each entity a query's `data` is given, and throws for one that matches none of the system's queries.** An entity kept from an earlier run (in `local` state or a closure) went on reading after it left its query (gained a `without` component, lost a `with` one, or a relation filter stopped matching), so a homing shot kept chasing a target that had died. Now, on a runtime made with `debug`, the read throws: - -``` -Game/Homing used Game/Position of entity 12, which matches none of its queries: an entity kept from an earlier run is not checked against its query again. Keep entity.id and resolve it with queries.x.get(id) in each run, which is fallible -``` - -An entity from another query of the same system still works, as before: the check passes when any of the system's queries matches. A despawned entity, and one that lost the slot's component, keep their own errors. Runtimes without `debug` read the entity as before. The check costs a few nanoseconds per slot call on a debug runtime (about 0.03 ms of a 0.30 ms debug step in a top-down shooter's crowd scene). - -If your tests or devtools sessions now throw, keep the entity's id and resolve it in each run: - -```diff --local.target ??= queries.targets.each()[0] --aimAt(queries.targets.data.position.get(local.target)) -+local.target ??= queries.targets.each()[0]?.id -+const found = local.target === undefined ? undefined : queries.targets.get(local.target) -+if (found?.ok) aimAt(queries.targets.data.position.get(found.value)) -``` diff --git a/.changeset/kept-query-values-copies.md b/.changeset/kept-query-values-copies.md deleted file mode 100644 index 01a90e30..00000000 --- a/.changeset/kept-query-values-copies.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -`ritmo/no-kept-query-values` follows entities and `data` through more forms. Newly reported, with the message saying to keep `entity.id` and resolve it with `queries.x.get(id)`, or to keep plain values from `queries.x.rows()`: - -- entities copied or picked into a kept value: `local.list = Array.from(queries.x.each())`, `.map((e) => e)` and `.reduce((best, e) => ...)` results kept in `local` or a module variable, `const [first] = queries.x.each()` then `local.target = first`; -- `Object.assign(local, { target: queries.x.each()[0] })`; -- a context destructured or renamed: `({ queries: q })` then `q.x.data`, `({ queries: { x } })` then `x.data`, `({ local: kept })` then `kept.target = ...`, and `(ctx) => { ctx.local.target = ctx.queries.x.each()[0] }`; -- a system function declared apart and passed to `Game.System(...)` or `Game.Inspector(...)` by name in the same file, keeping an entity in a module variable (`cache.set(1, entity)`). - -Ids stay unreported in each form (`Array.from(queries.x.each(), (e) => e.id)`, `first?.id`). diff --git a/.changeset/keyboard-shortcuts.md b/.changeset/keyboard-shortcuts.md deleted file mode 100644 index db9df2d4..00000000 --- a/.changeset/keyboard-shortcuts.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@typeonce/ritmo-browser": minor ---- - -**Breaking: `Keyboard.actions` leaves browser shortcuts to the browser.** A key pressed with Cmd, or with Ctrl, is no longer `preventDefault`-ed and presses no action, so Ctrl+R / Cmd+R reload the page instead of firing a `restart` bound to `"r"`, and Ctrl+F, Ctrl+L, and Cmd+A work on bound keys. AltGr reports Ctrl and Alt together and types characters on many layouts, so a key pressed with both still presses its action. A game that binds Control or Meta to an action (`"Control"`, `"Meta"`, `Keyboard.code("ControlLeft")`, ...) keeps every key pressed with them, as before, for as long as its bindings hold one. `Keyboard.presses(...)` skips shortcuts too, so Ctrl+R on a settings screen binds nothing; Control pressed alone is still offered. - -`KeyEvent` gains optional `ctrlKey`, `metaKey`, and `altKey`, which a real `KeyboardEvent` has. A test host that sends none behaves as before. - -Two fixes for keys whose release the page never sees. macOS sends no keyup for a key released while Cmd is down, so the action stayed held until a blur: Cmd's own keyup now lets go of every held key but the modifiers. And a key still down after that, or after a window blur, is pressed again at its next auto-repeat, where before it stayed released until pressed again. A repeat of a held key is still no new press, and neither is one of a key that went down in a form field, as a shortcut, or before a rebind bound it. - -A game whose players press bound keys with Ctrl held (a sneak or modifier key on Ctrl, read alongside other actions) binds Control as an action, which keeps every key pressed with it the game's: - -```diff --const keys = { jump: [" "], fire: ["f"] } as const -+const keys = { jump: [" "], fire: ["f"], sneak: ["Control"] } as const -``` diff --git a/.changeset/lint-every-skill.md b/.changeset/lint-every-skill.md deleted file mode 100644 index d8ce7fa9..00000000 --- a/.changeset/lint-every-skill.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-devtools": patch ---- - -`ritmo-devtools lint` notes every skill `@typeonce/ritmo` ships that the project does not link, read from the installed package's `skills` folder. It checked only `ritmo`, `ritmo-prototype`, `ritmo-onboarding`, and `ritmo-polish`, so an unlinked or copied `ritmo-pixel-art`, `ritmo-fx`, `ritmo-sounds`, or `ritmo-music` went unnoticed. The README's replay recorder snippet now imports `ReplayViteSink` and `ReplayVitePlugin` from their subpaths and records with `HostClock.standard`, and its list of `run.data.stop` reasons is complete. diff --git a/.changeset/lint-mismatched-versions.md b/.changeset/lint-mismatched-versions.md deleted file mode 100644 index 5150ec97..00000000 --- a/.changeset/lint-mismatched-versions.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@typeonce/ritmo-devtools": minor ---- - -`ritmo-devtools lint` fails on Ritmo packages installed at different versions. Every package depends on `@typeonce/ritmo` at its own minor, so a game with core 0.23 and browser 0.22 got a second, hidden copy of core 0.22 inside browser, and then type errors between the two copies that named neither. From the working directory, the lint now finds every `@typeonce` package the game resolves and every copy those resolve in turn (npm and yarn trees, nested or hoisted, and pnpm's store), and when they are not one version prints one warning naming each copy and the fix, and exits with 1: - -``` -warning mismatched-versions @typeonce packages: @typeonce/ritmo-browser 0.22.0 is installed with @typeonce/ritmo 0.23.0 (and a second @typeonce/ritmo 0.22.0 under @typeonce/ritmo-browser): ... - fix: install the same version of every @typeonce package -``` diff --git a/.changeset/literal-descriptor-names.md b/.changeset/literal-descriptor-names.md deleted file mode 100644 index d0c054e2..00000000 --- a/.changeset/literal-descriptor-names.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: a descriptor made with a name typed `string` is no longer a descriptor.** A helper typed `(name: string) => Descriptor.Component()(name)` gave every descriptor it made one type, so the compiler accepted one for another (an entity of a query over one in a slot of the other, a despawn under the other's permission), and the mix-up surfaced at runtime as "An entity was used with the data of ... of a runtime it does not belong to". Every constructor that takes a name (`Descriptor.Component`, `ConstructedComponent`, `TransientComponent`, `Tag`, `EntityState`, `Resource`, `ConstructedResource`, `TransientResource`, `Event`, `Service`, `Hierarchy`, `Relation`, `Game.StateMachine`, `Game.EntityScope`, `EntityScope.make`, and `Clock.resource`) now returns, for a `string` name, a value no API takes, and the first place it is used fails with: - -``` -a descriptor's name is a literal type: pass the name as a literal or `as const`, or make the helper generic over it, (name: Name) -``` - -Make the helper generic over the name: - -```diff --const counter = (name: string) => Descriptor.Component()(name) -+const counter = (name: Name) => Descriptor.Component()(name) -``` - -Literal names (`Descriptor.Component()("Game/Health")`), names `as const`, and template literal types (`` `Game/${string}` ``) work as before. diff --git a/.changeset/machine-transitions-lint.md b/.changeset/machine-transitions-lint.md deleted file mode 100644 index bb930936..00000000 --- a/.changeset/machine-transitions-lint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": patch ---- - -`ritmo/no-state-spread` names `nextMachines.mode.transition({ from, to, data })` as the way to move a state machine, the compare-and-set move state machines now have. diff --git a/.changeset/machine-transitions.md b/.changeset/machine-transitions.md deleted file mode 100644 index b402b7d6..00000000 --- a/.changeset/machine-transitions.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -State machines take the same transition graph and compare-and-set move as `Descriptor.EntityState`, so both kinds of state are written one way. `Game.StateMachine(name, states, { transitions })` declares, for every state, the states it may move to (`[]` for one with no way out), alone or next to `within`. `nextMachines.x.transition({ from, to, data })` queues the move with all of `to`'s fields only while the machine's committed state is `from`; otherwise it queues nothing and returns a `MachineStateMismatch` failure (`SubStateInactive` for a sub-state whose parent is elsewhere). A `to` the graph does not allow from `from`, an unknown state, and missing or extra fields do not compile. Without a graph any pair of states is accepted. The queued move commits at `applyStateTransitions(...)` like `set`, and only while the machine is still in `from`: a move queued from a transition schedule while the machine is leaving `from` is dropped at the following marker (traced as a `stale` transition, a new `Debug.TransitionEvent` outcome). A machine that declares `transitions` moves only with `transition`: `set` and `setIfChanged` do not compile on it, with an error naming `transition`, so every move it makes is one the graph lists. For a move from whatever state it is in, pass the state read from `machines.x.get()._tag` as `from`: with a union `from`, `to` must be a move every one of them allows. A machine without `transitions` keeps `set` and `setIfChanged` as they were. - -```ts -const Round = Game.StateMachine("Game/Round", { - Lobby: {}, - Playing: z.object({ round: z.int() }), - Over: z.object({ winner: z.int() }) -}, { transitions: { Lobby: ["Playing"], Playing: ["Over"], Over: ["Lobby"] } }) -``` - -```diff --nextMachines.round.set({ _tag: "Over", winner: 0 }) -+nextMachines.round.transition({ from: "Playing", to: "Over", data: { winner: 0 } }) -``` - -Breaking: - -- An entity state declared with `transitions` has no `set`, like a machine: move it with `data.x.transition(entity, { from, to, data })`, or change the current state's fields with `update`. `setRaw` stays, for data from outside the simulation. An entity state without `transitions` keeps `set`. -- `nextMachines.x.reset()` is `nextMachines.x.cancelPending()`: it drops the queued value, next to `getPending()`; it never reset the machine. -- `Game.System.transition(M)` is `Game.System.readTransition(M)` (and `System.transition` is `System.readTransition`), like `readTransitionEvent`: it declares read access to the transition being applied, and `transition` now names the move. -- `Machine.MachineStateMismatch` is `Machine.MachineStateMismatchError` and `Machine.SubStateInactive` is `Machine.SubStateInactiveError`, like `EntityStateMismatchError`. The `_tag` values are unchanged. -- `Machine.UpdateResult` is `Machine.CompareAndSetResult`: it is what both `machines.x.update(...)` and `nextMachines.x.transition(...)` return. -- `Descriptor.EntityStateTransitions` is `Descriptor.StateTransitions`: it is the graph of both kinds of state. -- `Machine.StateMachine(...)`, `Machine.read`, `Machine.update`, `Machine.write`, `Machine.transition`, and `Machine.readTransitionEvent` are removed: they duplicated `Game.StateMachine(...)` and `Game.System.machine` / `updateMachine` / `nextState` / `readTransition` / `readTransitionEvent` under other names, and nothing else called them. - -```diff --data.contact.set(entity, { _tag: "air", vy: 2, coyote: 0 }) -+data.contact.transition(entity, { from: "ground", to: "air", data: { vy: 2, coyote: 0 } }) --nextMachines.round.reset() -+nextMachines.round.cancelPending() --transitions: { round: Game.System.transition(Round) } -+transitions: { round: Game.System.readTransition(Round) } --type Failed = Machine.MachineStateMismatch<"Lobby" | "Playing"> -+type Failed = Machine.MachineStateMismatchError<"Lobby" | "Playing"> --type Updated = Machine.UpdateResult -+type Updated = Machine.CompareAndSetResult --type Moves = Descriptor.EntityStateTransitions -+type Moves = Descriptor.StateTransitions -``` - -A `transitions` key that is not a state now fails with a message naming the fix (`this key is not a state: ...`) instead of `not assignable to type 'never'`, for entity states too, and `machines.x.update(...)`'s extra-field error names `nextMachines.x.transition(...)` as the way to move. The `ritmo` skill tells games to declare the graph and move with `transition`. diff --git a/.changeset/math-negative-limits-and-distance.md b/.changeset/math-negative-limits-and-distance.md deleted file mode 100644 index d834a4f3..00000000 --- a/.changeset/math-negative-limits-and-distance.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@typeonce/ritmo-math": patch ---- - -`Vec2.moveToward` and `Vec2.clampLength` count a limit below 0 as 0, as `Scalar.moveToward` and `Angle.turnToward` already did. A speed that went negative (a slow effect minus friction) moved the point away from its target, and a negative cap flipped the vector; now `moveToward` returns `current` unchanged and `clampLength` the zero vector. A `NaN` limit counts as 0 too, as in `Scalar.moveToward`. - -`FlowField.distance` gives a distance wherever `way` gives a direction. From a blocked cell (a body pressed against a wall or into a corner) it was `undefined` even where `way` led it out, so a chaser ranking targets by distance skipped it. It is now the distance out along the steps `way` takes: to the open cells it heads for, plus their distance. It is `undefined` exactly where `way` is, except where the body is already there. Every open cell's distance is unchanged. - -`FlowField`'s docs say which wall sizes the field can step through: a cell is blocked by its center alone, so a wall `width` by `height` can fall between open cells exactly when `hypot(max(0, cellSize - width), max(0, cellSize - height)) > 2 * clearance`. No step crosses a wall at least `cellSize - clearance * √2` thick both ways, and none crosses any wall when `cellSize <= clearance * √2`; with thinner walls, use smaller cells or a larger clearance. Behaviour is unchanged. diff --git a/.changeset/math-walls-closing-in.md b/.changeset/math-walls-closing-in.md deleted file mode 100644 index f32055b0..00000000 --- a/.changeset/math-walls-closing-in.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@typeonce/ritmo-math": patch ---- - -`Collide2.moveCircle` no longer passes through walls where they close in on a circle. Pushing off one wall by the skin could leave the circle a hair inside another, and a wall the circle started in was ignored for the rest of the move, so the slide went through it: a circle sliding between a crate and a wall left the room on the next move, and one pushed into an acute corner of segments came out the other side. Now a circle that starts outside every wall ends outside every wall, also in acute corners and gaps narrower than it, and stops there (`blocked`) when nothing is left to slide along. A wall it touches or is inside, while moving into it, is a contact at once; where the skin would push it into another wall, it stays touching instead. - -`Collide2.pushOut` leaves the deepest wall first, leaves both walls of an acute corner at once (pushing out of one alone pushed into the other), and takes a center inside a box out through the nearest side with room, so a circle spawned in a corner or in a crate against a wall comes out clear of every wall. Only walls with no room between them (a gap narrower than the circle) can leave it overlapping. - -`FlowField` finds the way to a goal backed into a corner. When the goal's cell and every cell it could step to are blocked (a player pushed into a room corner, or standing against a wall more than one blocked cell deep), the search starts from the nearest open cells the goal can see in a straight line, where every chaser used to get `undefined`. A body in such a blocked cell gets `way` out to the nearest open cell it can see. Every way the field gave before is unchanged. - -`moveCircle` is also faster: it skips walls outside the bounds of the swept circle before testing them. diff --git a/.changeset/name-the-schema.md b/.changeset/name-the-schema.md deleted file mode 100644 index 3406c52f..00000000 --- a/.changeset/name-the-schema.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -The docs show how to name a game's schema type, so type errors print `QueryEntity` 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, Root).schema -type SchemaOf = typeof schema -export interface GameSchema extends SchemaOf {} -const named: GameSchema = schema -export const Game = Schema.bind(named, Root) -``` - -A helper that takes a query's entities and its `data` is typed with the existing `QueryIteration` (from `@typeonce/ritmo/System`) and passed `queries.walls`, as the `QueryEntity` and `QueryIteration` docs now say, instead of a hand-written `{ each(): ...; data: ... }` type. - -The 0.21.0 entry now says it replaces 0.17's optional-slot fix, so a game upgrading from 0.16 rewrites `data.x.present` straight to `data.x.has(entity)`. diff --git a/.changeset/netcode-events-lost-resync.md b/.changeset/netcode-events-lost-resync.md deleted file mode 100644 index 8bcd1a1a..00000000 --- a/.changeset/netcode-events-lost-resync.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@typeonce/ritmo-netcode": minor ---- - -`EventsLost` has a `reason`: `"unread"` (the reader fell behind, as before) or `"resync"`. A peer one or two ticks ahead of seat 0 can confirm ticks that seat 0's next save makes idle; when the resync runs them again with other events, `lost()` now reports those ticks with `reason: "resync"`, where before the events it had handed out as confirmed stayed in the host's sound or score with no report. The new run's events are still not handed out a second time, and a tick that runs again with the same events (compared by value) reports nothing. `lost()` returns one report per call, a `"resync"` one first. - -Code that builds an `EventsLost` (a test double) adds the field: - -```diff --const lost: Netcode.EventsLost = { _tag: "EventsLost", from: 10, to: 12 } -+const lost: Netcode.EventsLost = { _tag: "EventsLost", from: 10, to: 12, reason: "unread" } -``` diff --git a/.changeset/netcode-local-input-schema.md b/.changeset/netcode-local-input-schema.md deleted file mode 100644 index c5147ebe..00000000 --- a/.changeset/netcode-local-input-schema.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@typeonce/ritmo-netcode": minor ---- - -**Breaking: the local input goes through the input schema, and one the schema rejects throws.** `advance(local)` (and `syncTest`'s `advance`) now encodes `local`, writes it as JSON, and decodes it with the seats' schema, as every other peer does with the message, and this peer simulates the decoded value. Before, the local peer simulated the raw value and the others its decoded form: a schema that normalizes (a clamp, a default, a strip) desynced the match on the first input it changed, and an input the schema rejects (an analog `dx` of `0.5` where the schema wants a whole number; it compiles as `number`) was dropped by every other peer, so the match stalled for good with no error. - -An input the schema rejects now throws from `advance` with `Netcode: the local input does not decode with the input schema: `, like one that does not encode already did. The fix is to hand `advance` the schema's output type, for example: - -```diff --session.advance({ dx: stick.x, fire }) -+session.advance({ dx: Math.round(stick.x), fire }) -``` - -`Netcode.seats` checks `idle` the same way, so an `idle` that does not survive JSON fails there with `InvalidOptions`. diff --git a/.changeset/netcode-playbook-restarted.md b/.changeset/netcode-playbook-restarted.md deleted file mode 100644 index 58667f44..00000000 --- a/.changeset/netcode-playbook-restarted.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -The `ritmo` skill's netcode playbook says that a reloaded page ends the match (every peer ends with `Disconnected` `reason: "restarted"`, so `onFailure` offers a new match), that a HUD draws who everyone waits for from `status().waiting`, and that the rollback check looks for rollbacks on the peer ahead (the one behind may always have the input in time). diff --git a/.changeset/netcode-queued-commands.md b/.changeset/netcode-queued-commands.md deleted file mode 100644 index db89f31d..00000000 --- a/.changeset/netcode-queued-commands.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-netcode": patch ---- - -`SyncFailed`'s doc names `QueuedCommands`: a session whose `simulate` leaves a command queued after its last marker ends with it on the first checksum or save, since `runtime.checksum()` and `runtime.snapshot()` now fail then. End `simulate` with `Game.Schedule.applyDeferred()`. Peers that restore a save no longer keep their own transient resources or report the replaced world's despawns (see `@typeonce/ritmo`), which made a match desync after its first save or after every resync. No code changes. diff --git a/.changeset/netcode-receiver-bounds.md b/.changeset/netcode-receiver-bounds.md deleted file mode 100644 index 3f4899bf..00000000 --- a/.changeset/netcode-receiver-bounds.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-netcode": patch ---- - -A session enforces the sender's own limits on what it receives, so another peer cannot grow its memory without bound: an inputs message with more than 64 inputs, or a checksum longer than 16 characters, is malformed, and inputs or checksums for ticks further ahead than an honest peer can be (more than `2 * (maxRollback + inputDelay) + 64` ticks past this peer's last input, `adaptiveDelay.max` for an adaptive delay) are dropped. Both count in `status().dropped`. An honest match whose peers give the same `maxRollback` and delay drops nothing, through a resync and a seat that hides and returns; a peer given a larger `maxRollback` may see some inputs dropped until they are in reach, and sends them again. diff --git a/.changeset/netcode-restarted-seats.md b/.changeset/netcode-restarted-seats.md deleted file mode 100644 index 91ceb57f..00000000 --- a/.changeset/netcode-restarted-seats.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@typeonce/ritmo-netcode": minor ---- - -**A seat whose page reloads ends the match instead of deadlocking it.** A new session for a seat already in the match restored seat 0's stored save and waited for inputs seat 0 had pruned, while seat 0 ignored the new session's inputs: both stayed `Stalled` with no timeout. Now every peer ends with `Disconnected` and the new reason `"restarted"`, naming the seat, and so does the new session (at once when seat 0's save reaches it, or after `timeoutMS` of hearing a match that runs without it). There is no way back into the match: `onFailure` shows that the match ended and offers a new one. Sessions are told apart by their `hostClock` reading when made, carried in hellos, pings, and saves. - -**`status().waiting`** names what a stalled session waits for: `{ seat, tick, forMS }` (the seat whose input for `tick` this peer cannot run past, and for how long by `hostClock`), or `undefined` while it runs, waits for a save, or is halted. A HUD draws "waiting for seat 1"; a headless test says why a match stopped. - -Code that matches every `Disconnected` reason, or builds a `Status` (a test double), adds the new member: - -```diff - switch (failure.reason) { - case "closed": return "the connection closed" - case "timeout": return `seat ${failure.seat} went quiet` -+ case "restarted": return `seat ${failure.seat} reloaded: start a new match` - } --const status: Netcode.Status = { ...fields, delayChanges: 0 } -+const status: Netcode.Status = { ...fields, delayChanges: 0, waiting: undefined } -``` - -Session bugs over a jittery, lossy link are fixed: - -- Inputs of a newer epoch that overtook their save were taken into the old timelines, with the acknowledgements they carry: a peer could prune its own inputs for ticks the save then ran again and stall for good, or desync after the resync. Inputs (and their acknowledgements and pacing) now apply only in the epoch they were sent in; the sender sends them again. -- A hidden tab announced its hide once, so under loss the others could miss it and end with `Disconnected` after `timeoutMS`. The announcement now carries the inputs the others still need, goes out five times, and every peer that heard it passes it on with its pings while the seat is hidden (with two seats, it is lost only when all five copies are). Its inputs count in a later epoch too, from that save's floor on; one of a newer epoch waits for its save, but the seat counts as hidden at once, so a seat that hides right after a save is not timed out by a peer whose save was lost. -- A message overtaken by a later one lowered the acknowledgements a peer held, so a seat that hid could announce fewer inputs than the others had already run with: a desync with a long input delay (rollback), and after a resync (lockstep). Acknowledgements now only grow. -- A seat back from a hide under `"resync-on-return"` sent checksums of the ticks it ran on guesses while it waited for its save, and the others ended `Desynced`. A seat played around sends no checksums. - -The session protocol is version 3: a peer on an earlier version ends with `ProtocolMismatch`. Give every peer the same release. diff --git a/.changeset/no-kept-query-values.md b/.changeset/no-kept-query-values.md deleted file mode 100644 index 0cb88e4b..00000000 --- a/.changeset/no-kept-query-values.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -New rule `ritmo/no-kept-query-values`: reports an entity from `queries.x.each()` or a query's `data` kept past its run, in `local` state (directly, in a literal, pushed or set into a collection there, or captured by a closure stored there) or in a variable outside the `Game.System` / `Game.Inspector` callback. Kept data throws in the next run, and a kept entity is not checked against its query again. The report says to keep `entity.id` and resolve it with `queries.x.get(id)`, or to keep plain values from `queries.x.rows()`. Add it to `.oxlintrc.json` for every file: - -```jsonc -"ritmo/no-kept-query-values": "error" -``` diff --git a/.changeset/one-name-per-lookup.md b/.changeset/one-name-per-lookup.md deleted file mode 100644 index cbb4630d..00000000 --- a/.changeset/one-name-per-lookup.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: `lookup.parent`, `Result.isSuccess`, and `Result.isFailure` are removed; each had another name for the same job.** `lookup.parent(id, R)` was `lookup.related(id, R)` for a hierarchy, and `result.ok` already narrows a `Result`. `lookup`'s methods now say in their JSDoc what each one is for and which query slot or query handle method to use instead. - -```diff --const hand = lookup.parent(item.id, ChildOf) -+const hand = lookup.related(item.id, ChildOf) - --if (Result.isSuccess(made)) use(made.value) --if (Result.isFailure(made)) report(made.error) -+if (made.ok) use(made.value) -+if (!made.ok) report(made.error) -``` diff --git a/.changeset/oxlint-readme-snapshot.md b/.changeset/oxlint-readme-snapshot.md deleted file mode 100644 index f6d854c8..00000000 --- a/.changeset/oxlint-readme-snapshot.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": patch ---- - -The `handle-runtime-result` docs say why `runtime.snapshot()` and `runtime.checksum()` return a `Result`: a schema's `encode` may reject a value, and a save cannot hold commands still queued. No change for games. diff --git a/.changeset/pause-while-devtools.md b/.changeset/pause-while-devtools.md deleted file mode 100644 index e78b4755..00000000 --- a/.changeset/pause-while-devtools.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-devtools": patch ---- - -Follows core's `pauseWhile`: a system description lists `pauses while` conditions, and a paused run reads `paused: is true (holding its stream entries)`, since a pause now names the condition that holds. diff --git a/.changeset/pause-while.md b/.changeset/pause-while.md deleted file mode 100644 index 9ea2ce09..00000000 --- a/.changeset/pause-while.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -**Breaking: `pauseWhen` is now `pauseWhile`, and it pauses while a condition holds.** `Game.Schedule.pauseWhen(conditions, ...)` and a system spec's `pauseWhen: conditions` paused the systems while a condition *failed*, the same polarity as `when`, so the name read backwards: `pauseWhen([Game.Condition.inState(HitStop, "Frozen")], Move)` ran `Move` only during the freeze, with no error. `Game.Schedule.pauseWhile(conditions, ...)` and the spec key `pauseWhile` pause while *any* of their conditions holds (`pauseWhile([frozen, cutscene])` pauses during either). `when` keeps its meaning (runs while all hold), so the two read together: `{ when: [playing], pauseWhile: [frozen] }` runs when playing and pauses while frozen. Everything else about pausing is unchanged: a paused reader holds its stream entries, marker steps in the group still run, and `when` and pauses nest as before. - -`pauseWhen` no longer compiles, so no call keeps its old meaning silently. Rename it and invert each condition: drop a `Game.Condition.not(...)` the old call needed, and add one where it had none. A list `pauseWhen([a, b])` (paused while `a` or `b` failed) becomes `pauseWhile([Game.Condition.not(a), Game.Condition.not(b)])`. - -```diff - const frozen = Game.Condition.inState(HitStop, "Frozen") --Game.Schedule.pauseWhen([Game.Condition.not(frozen)], Move, ApplyHits) -+Game.Schedule.pauseWhile([frozen], Move, ApplyHits) --Game.System("Game/Read", { events, pauseWhen: [ready] }, run) -+Game.System("Game/Read", { events, pauseWhile: [Game.Condition.not(ready)] }, run) -``` - -Debug output follows the name: `SystemDescription.pauseWhen` is `pauseWhile`, the `stream-loss-unread` lint says `under pauseWhile`, and a `system.skipped` event with `held: true` carries the `pauseWhile` condition that held. diff --git a/.changeset/performance-playbook.md b/.changeset/performance-playbook.md deleted file mode 100644 index 58d0248b..00000000 --- a/.changeset/performance-playbook.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -The `ritmo` skill has a performance playbook (`playbooks/performance.md`, listed in `SKILL.md`): measure ticks on a real host clock without `debug`, name the system with `session.report()` on the same clock (a test clock makes every timing 0), fix the usual causes, and compare the same number before and after. - -`SETUP.md` starts with what a game needs: Node 22.19 or newer for the commands the setup and the skills run, and TypeScript 5.9 or newer with `strict` on and `noErrorTruncation` off. - -The spatial grid reference says where the grid lives in a schedule that rewinds, where `local` does not compile: in the system's body, made each run. diff --git a/.changeset/plugin-readme-random.md b/.changeset/plugin-readme-random.md deleted file mode 100644 index ccc89d63..00000000 --- a/.changeset/plugin-readme-random.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": patch ---- - -The README's `ritmo/no-brand-cast` example compiles: the random resource is `Descriptor.ConstructedResource(Random.codec)("Game/Rng")`, given `Random.seed(1)` in `Game.Runtime.make`, and the reported line is the raw cast `1 as Random.State`. `Random.resource` never existed. diff --git a/.changeset/pointer-chords.md b/.changeset/pointer-chords.md deleted file mode 100644 index 42607c8a..00000000 --- a/.changeset/pointer-chords.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-browser": patch ---- - -`Pointer.actions` sees mouse chords. Browsers send `pointerdown` only for the first button down and `pointerup` only for the last one up; a button pressed or released while another is held arrives as a `pointermove`, which the pointer ignored. A left click while aiming with the right button was never seen, and the first button let go during a chord stayed held until a window blur. `PointerEventLike` now has an optional `buttons` (the `PointerEvent.buttons` bitmask), and every down, up, and move that carries it holds exactly those buttons, with the same press and release edges as a down or up (a tap of the second button shorter than a frame reports `pressed` and `released` once). The click that takes the pointer lock is still no press, and neither is any button that goes down while the pointer is not locked, until it comes up. A pen's barrel button is `secondary`, as before; its eraser presses nothing. Browsers always send `buttons`, so a game needs no change; a host that sends no `buttons` behaves as before. The pointer now takes capture on the first press only. diff --git a/.changeset/query-rows.md b/.changeset/query-rows.md deleted file mode 100644 index 7936907b..00000000 --- a/.changeset/query-rows.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -`queries.x.rows()` reads every entity of a query into plain values: each row is `{ id, values }`, with one field per slot of the query's `selection` (`undefined` for an optional slot whose entity lacks the component), in `each()` order and with the query's filters applied. The values stay readable after the run, so a view layer, an inspector's result, a memoized helper, or a test can keep them, where a kept `queries.x.data` throws. - -```ts -const BuildingView = Game.Inspector("Town/BuildingView", { queries: { buildings: BuildingRead } }, ({ queries }) => - queries.buildings.rows()) - -for (const { id, values } of runtime.inspect(BuildingView)) panel.show(id, values.tile, values.site?.progress) -``` - -`QueryValues` and `QueryRow` (from `@typeonce/ritmo/Query`) type them. A row carries the entity's `id`, not the entity, so it cannot be read through `data` later: resolve it with `queries.x.get(id)` to act on the entity again. `rows()` reads every slot of every entity and makes an object per row (about 50 ns per entity with two slots in the new `query/rows-10k` bench case, and about 110 ns with five, against about 20 ns for five slots read through `each()` and `data`), so systems keep reading `data`. - -A game that wrote its own rows helper can replace it: - -```diff --type Row = { readonly entity: QueryEntity; readonly data: { readonly [K in keyof QueryData]: ReturnType[K]["get"]> } } --const rowsOf = (query) => query.each().map((entity) => ({ entity, data: /* every slot's get(entity), with casts */ })) --const buildings = rowsOf(queries.buildings) -+const buildings = queries.buildings.rows() // ReadonlyArray> -``` diff --git a/.changeset/random-wide-spans.md b/.changeset/random-wide-spans.md deleted file mode 100644 index e81322cc..00000000 --- a/.changeset/random-wide-spans.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -`Random` handles its edge cases, and every value a game drew before is drawn the same (replays and saves are unchanged): - -- `integer(min, maxExclusive)` with a span wider than 2**32 returned only some integers (`integer(0, 2 ** 40)` gave only even numbers): such a span now takes two draws (now and then more) and every integer in it is equally likely. A span up to 2**32 still takes one draw and gives the same sequence. A span past `Number.MAX_SAFE_INTEGER` throws (`Random.integer needs a span of at most Number.MAX_SAFE_INTEGER`), as bad bounds already did. -- `range(min, max)` returned `Infinity` when `max - min` overflowed (`range(-1e308, 1e308)`); it now stays in range. -- `weighted` returned `undefined` when the usable weights added up past the largest number; it now throws (`Random.weighted needs usable weights that add up to a finite total`). diff --git a/.changeset/render-node-follows-renderable.md b/.changeset/render-node-follows-renderable.md deleted file mode 100644 index 8c789d4e..00000000 --- a/.changeset/render-node-follows-renderable.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@typeonce/ritmo-render": minor ---- - -**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`: - -```diff --const Sprite = Descriptor.Component<{ texture: string; frame: number }>()("Game/Sprite") -+const Sprite = Descriptor.Component<{ texture: string }>()("Game/Sprite") -+const Frame = Descriptor.Component()("Game/Frame") - - RenderSync.system(Game, { - renderable: Sprite, -+ select: { frame: Game.Query.read(Frame) }, -+ redrawOn: [Frame], -- apply: (node, { renderable }) => node.gotoAndStop(renderable.frame) -+ apply: (node, { data }) => node.gotoAndStop(data.frame.get()) - }) -``` - -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/render-related-lookup.md b/.changeset/render-related-lookup.md deleted file mode 100644 index e157fb08..00000000 --- a/.changeset/render-related-lookup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-render": patch ---- - -`RenderSync` finds a node's parent with `lookup.related`, since core removed `lookup.parent`. No change for games. diff --git a/.changeset/render-sync-restore.md b/.changeset/render-sync-restore.md deleted file mode 100644 index 38dad8c2..00000000 --- a/.changeset/render-sync-restore.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-render": patch ---- - -`RenderSync` stays correct across `runtime.restore`, which no longer reports the replaced world's entities as despawned: when its removal reads are lagged it destroys every node whose entity no longer matches, as before, and also the node of every entity added since, so an id the save gave to another entity gets a new node made from that entity's values. No change for games. diff --git a/.changeset/replay-window-not-compared.md b/.changeset/replay-window-not-compared.md deleted file mode 100644 index 592309dd..00000000 --- a/.changeset/replay-window-not-compared.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@typeonce/ritmo-devtools": minor ---- - -A replay whose window starts from a save no longer reports a false divergence. Once a recording outlasts `keep` (or after `Replay.extract`), the file starts from a save of a later tick, and a save does not hold events in flight or what each `added()` / `changed()` reader had already seen, so an ordinary game replayed to other checksums and `play()` said `DIVERGED ...: the simulation is not deterministic, or the code changed since recording`. Neither was true. Such a replay now plays as before (stepping, `why`, invariants) and says `starts from a save at tick 14: checksums are not compared (a save does not hold events in flight or what each reader had already seen)`. A file that starts at tick 0, where the recorder started, is compared as before. `Verify.acrossEngines` and `ritmo-devtools verify` skip the recording's checkpoints for such a file in the same way and say so; they still compare the two Node runs and every engine. - -Breaking: `play().data.diverged` is replaced by `play().data.checkpoints`, which is `{ _tag: "Compared", diverged }` or `{ _tag: "NotCompared", reason }`, so a test can tell "not compared" from "compared and equal": - -```diff -- expect(played.data.diverged).toEqual([]) -+ expect(played.data.checkpoints).toEqual({ _tag: "Compared", diverged: [] }) -``` - -`Verify.Verified` has `recording: "matched" | "notCompared"`. diff --git a/.changeset/rewind-in-devtools.md b/.changeset/rewind-in-devtools.md deleted file mode 100644 index dea92e03..00000000 --- a/.changeset/rewind-in-devtools.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@typeonce/ritmo-devtools": minor ---- - -Sessions and replays understand rewinds (rollback netcode, client prediction): - -- `why`, `whyResource`, `journal`, and `system` show each `runtime.rewind` as a line between the frames it separates, under every filter: `f20 - rewind to tick 81, undoing f13-f20`. A change whose `before` is not the previous line's `after` has the rewind above it; before, undone ticks read as real history. A filter's `limit` counts the other lines and keeps the rewinds between and after them. `report()` counts them (`Report.rewinds`) and lists the count in its first line. `Format.LineKind` has `"rewind"`, and `Format.rewind(event, undid)` renders one. -- `Replay.recorder` forgets the ticks a rewind undid, so a file recorded on a rollback peer holds each tick as it last ran and replays without diverging. It used to record every run of a tick as a new tick. - -A `switch` over `Format.LineKind` that checks it is exhaustive needs a `case "rewind"`. diff --git a/.changeset/rewind-readers.md b/.changeset/rewind-readers.md deleted file mode 100644 index 2a701190..00000000 --- a/.changeset/rewind-readers.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -`runtime.rewind` gives readers what a runtime that never rewound gives them: - -- A system of the rewound schedule whose first run came after the checkpoint (one in an `onEnter`/`onExit` schedule, or after a failing system) is back to never having run. It used to stay registered with its positions at zero, so it held every event, removal, and despawn published after the checkpoint and read them all on its next run, where a runtime that never rewound had dropped them. The frames ticked after a rewind also keep what the frames after the checkpoint kept the first time, so a reader's first run sees the same events either way. -- A system or inspector outside the schedule (a HUD, render sync) sees every resource the rewind put back as changed: `changedResource(...).changed()` was `false` for a value it had already drawn and the rewind undid. -- An entity the rewind took away and the ticks after it brought back (the same id) no longer matches `Game.Query.added(X)` a second time for a reader outside the schedule that had seen `X` on it: it matches `changed(X)`, as the `Checkpoint` module doc says. A component that reader never saw on that id is still `added`. A render system creating a node on `added` created two before. - -No code changes. diff --git a/.changeset/rewind-trace-event.md b/.changeset/rewind-trace-event.md deleted file mode 100644 index 355a3c09..00000000 --- a/.changeset/rewind-trace-event.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -`runtime.rewind` is traced: `debug.observe` and `debug.observeHost` deliver a `Debug.RewindEvent` (`{ type: "rewind", frame, tick, toTick, ok }`, ticks being change ticks), so tools can tell a rollback's ticks run again from new ones. `observeHost`'s `tick.start` carries the change tick it starts at (`tick`): a rewind undid the ticks that started at or after its `toTick`. - -**Breaking for host listeners that narrow by elimination.** `Debug.HostEvent` gained a member with no `schedules`, so a listener that rules out `send` and `failure.ignored` and then reads `event.schedules` no longer compiles. Handle the rewind first: - -```diff - runtime.debug.observeHost((event) => { - if (event.type === "send" || event.type === "failure.ignored") return -+ if (event.type === "rewind") return - if (!event.schedules.includes(simulate)) return -``` - -A `switch` over `Debug.TraceEvent` that checks it is exhaustive needs a `case "rewind"`. diff --git a/.changeset/root-mandatory-browser.md b/.changeset/root-mandatory-browser.md deleted file mode 100644 index 86759b4c..00000000 --- a/.changeset/root-mandatory-browser.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-browser": patch ---- - -The `Controls` and `InputCapture` examples declare their game's root and pass it to `Schema.bind`, which now takes one. diff --git a/.changeset/root-mandatory-render.md b/.changeset/root-mandatory-render.md deleted file mode 100644 index 096409fd..00000000 --- a/.changeset/root-mandatory-render.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo-render": patch ---- - -The `RenderSync.interpolate` example declares its game's root and passes it to `Schema.bind`, which now takes one. diff --git a/.changeset/root-mandatory.md b/.changeset/root-mandatory.md deleted file mode 100644 index 51424c3a..00000000 --- a/.changeset/root-mandatory.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@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`). - -Types a game spelled with the schema in the root's place name the root instead: - -```diff --type Id = Entity.EntityId -+type Id = Entity.EntityId -``` - -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` 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) -``` diff --git a/.changeset/runtime-result-callbacks.md b/.changeset/runtime-result-callbacks.md deleted file mode 100644 index 411824e1..00000000 --- a/.changeset/runtime-result-callbacks.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -`ritmo/handle-runtime-result` finds more dropped and misread results. Newly reported: - -- a result an arrow returns to a caller that drops it: `updates.forEach((u) => runtime.tick(u))`, `requestAnimationFrame(() => runtime.tick(update))`, also `addEventListener`, `requestIdleCallback`, `setTimeout`, `setInterval`, `setImmediate`, and `queueMicrotask`. Check it in a block body, or write `() => void runtime.tick(update)` to ignore it on purpose; -- a result left out of a sequence's value: `runtime.tick(update), draw()`; -- a result tested as a condition, which is always true since a `Result` is an object: `if (!runtime.tick(update))`, `if (runtime.restore(save))`, `runtime.rewind(checkpoint) ? a : b`, the left of `&&` / `||`. Test `.ok` instead: `if (!runtime.tick(update).ok)`; -- a method destructured from a runtime: `const { tick } = runtime`, then `tick(update)`; -- a runtime of any name made in the same file: `made.value.tick(update)` and `game.tick(update)` after `const made = Game.Runtime.make(...)` and `const game = made.value`. diff --git a/.changeset/runtime-robustness.md b/.changeset/runtime-robustness.md deleted file mode 100644 index a1126861..00000000 --- a/.changeset/runtime-robustness.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -Six runtime fixes: - -- **`runtime.restore` loads a large save.** A save of about 125,000 entities or more threw `RangeError: Maximum call stack size exceeded`, after the world had already been emptied and every checkpoint released, although `snapshot()` of the same world succeeded. It now restores, and a failed restore again changes nothing. -- **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.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 deleted file mode 100644 index 188b1996..00000000 --- a/.changeset/saves-and-loads.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -A load leaves nothing of the world it replaced, and a save holds the work still queued, so the same save gives the same world in every runtime that loads it (every peer of a netcode match). - -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 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: - - ```ts - if (despawned.entities.lagged()) rebuildFrom(queries.alive.each()) - ``` - -- `runtime.snapshot()` and `runtime.checksum()` fail with `{ _tag: "QueuedCommands", count }` while a command waits for a marker: a command is a function, which no save can hold, so the loaded world ran differently from the one that wrote it. End the schedule the save follows with its marker: - - ```diff - -const update = Game.Schedule(Input, Move, Spawn) - +const update = Game.Schedule(Input, Move, Spawn, Game.Schedule.applyDeferred()) - ``` - -- `Snapshot.WorldSnapshot` has a `nextStates` field: the next states queued for the following `applyStateTransitions`, by machine name (`{ state, ifChanged, from? }`, a `Snapshot.NextStateSnapshot`). `snapshot()` writes it, `checksum()` covers it, and `restore` validates it (`InvalidNextState`, or `InactiveSubState` for a queued sub-state whose parent is elsewhere) and queues it again, so the next marker applies it as in the runtime that wrote the save. A save written before has none and loads with nothing queued, so `Snapshot.version` stays 3. Code that builds a `WorldSnapshot` by hand (a test fixture, a migration that returns a new object) adds the field: - - ```diff - -const save: Snapshot.WorldSnapshot = { version: 3, gameVersion: 0, nextEntity: 1, entities: [], relations: {}, resources: {}, machines: {}, scopes: {} } - +const save: Snapshot.WorldSnapshot = { version: 3, gameVersion: 0, nextEntity: 1, entities: [], relations: {}, resources: {}, machines: {}, nextStates: {}, scopes: {} } - ``` - - 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. - -The `ritmo` skill's state playbook lists both breaking changes for an upgrade. diff --git a/.changeset/setup-root.md b/.changeset/setup-root.md deleted file mode 100644 index a8a25d94..00000000 --- a/.changeset/setup-root.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@typeonce/ritmo": patch ---- - -`SETUP.md` step 4 declares the root it binds the game under (`const Root = Schema.defineRoot("Game")`), so the snippet compiles as written. diff --git a/.changeset/state-spread-known-states.md b/.changeset/state-spread-known-states.md deleted file mode 100644 index a7004677..00000000 --- a/.changeset/state-spread-known-states.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@typeonce/oxlint-plugin-ritmo": minor ---- - -`ritmo/no-state-spread` reports a `_tag` set over a spread only when the file shows the spread value is a state, and reads more ways of writing the same re-tag. - -A spread value is a state when it is read from a state machine (`machines.x.get()`, `nextMachines.x.getPending()`) or from an entity state's slot the same function moves (`data.contact.get(entity)` next to `data.contact.transition(...)` or `data.contact.update(entity, { state, data })`), directly or through the variable holding it, or when the same function reads its `_tag` (`if (body._tag === "ground")`, `switch (state._tag)`, `const { _tag } = previous`). - -Newly reported, with the message saying to move with `data.x.transition(entity, { from, to, data })` or `nextMachines.x.transition({ from, to, data })`, or to write the new state's fields out: - -- `Object.assign({}, ground, { _tag: "air" })`; -- `{ ...ground, ...{ _tag: "air" } }`; -- `const { _tag, ...rest } = ground`, then `{ _tag: "air", ...rest }`. - -No longer reported: a spread value nothing in the file shows to be a state, such as a plain value tagged for an event (`events.explosion.emit({ ...position, _tag: "Explosion" })`). The compiler already rejects an `update` callback that changes `_tag` and `set` on a state declared with `transitions`. diff --git a/.changeset/system-failure-union.md b/.changeset/system-failure-union.md deleted file mode 100644 index ba611026..00000000 --- a/.changeset/system-failure-union.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -A system body that returns failures of different shapes now compiles, and the system fails with their union. Only failures TypeScript could merge into one type compiled before (two string literals); a string in one branch and an object in another, or two objects with different fields, did not: - -```ts -Game.System("Game/Buy", spec, ({ resources }) => { - if (resources.gold.get() < 5) return Result.failure("NotEnoughGold" as const) - if (resources.stock.get() === 0) return Result.failure({ _tag: "OutOfStock" as const }) -}) -// fails with "NotEnoughGold" | { _tag: "OutOfStock" }, and so does runtime.tryTick for its schedule -``` - -`System.SystemBody`'s second type parameter is now the body's whole return type, not its failure type; `System.BodyFailure` gives the failure type of a return type, and `System.SystemReturnFix` is the message a body that returns a value or a promise names. Game code passes neither, so nothing in it changes. diff --git a/.changeset/tools-fx-approval-and-signals.md b/.changeset/tools-fx-approval-and-signals.md deleted file mode 100644 index 62b57f28..00000000 --- a/.changeset/tools-fx-approval-and-signals.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@typeonce/ritmo-tools": minor ---- - -An fx approval covers everything `fx render` hands the game, a broken draft exits 3 in every `fx` command, and `kill` ends a stuck command. - -- **fx approvals.** Approving an effect (on the page, as before) now also keeps whether it loops, its canvas size, and its anchor, beside the draft, fps, and frame hashes. `fx notes` and `fx render` say when one of them changed (`changedSinceApproved: "its anchor moved from 12,12 to 0,0"`); before, an effect whose anchor moved still read as approved while the game drew it somewhere else. An approval stored before this keeps no anchor or loop: it is not reported as changed for that, but as `unrecorded: true`, with a `next` naming what it did not keep ("Approved, with no record of its anchor or whether it loops: ask the person to look again"), and is not counted among the approved. Ask the person to approve it again on the page. -- **Exit 3 for a broken draft.** `fx render` and `fx look` exit 3, as `fx check` does, for a draft that does not load or whose `draw` throws, names a color or ramp the bank does not have, or runs past the time limit, with `{ error, effect, draft, errors, next }` on stdout. Before, `fx render` exited 2 (a usage error) and `fx look` exited 2 for a draft that does not load and 0 for one whose `draw` throws. -- **Stopping.** SIGTERM and SIGINT end any command at once, even one stuck in synchronous work (a draft that loops as it loads, a large bake); before, the CLI listened for them on its event loop, so such a command outlived `kill` and `timeout` until SIGKILL. A review server still stops on either and exits 130. diff --git a/.changeset/tools-review-safety.md b/.changeset/tools-review-safety.md deleted file mode 100644 index bd685818..00000000 --- a/.changeset/tools-review-safety.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@typeonce/ritmo-tools": minor ---- - -The review pages answer only on this machine, and no longer lose what the agent wrote; every tool's `notes` reports the same things; and bad input is refused with what to change. - -- **Review servers.** A request whose `Host` is not `localhost`, `127.0.0.1`, or `[::1]` on the page's port (or, with `--host`, an address the server printed) gets 403, so a web page open elsewhere cannot reach a game's files by DNS rebinding. `fx review` reads a draft or a stand-in only inside the bank's folder, links followed, and `music review` writes only the song's hum (`.song.reference.wav`, a WAV). -- **No lost writes.** Every bank, review, and song save from a page carries the version of the file the page read; when the agent wrote the file meanwhile (takes from `sound vary`, a rewritten song, a resolved comment), the server answers 409 and writes nothing, and the page lays the person's change over the agent's and saves again. A save that fails stays owed on the sound and fx pages, as on the music page. Pages, servers, and commands write each file whole (to a file beside it, then renamed). -- **Notes.** `sound notes`, `fx notes`, and `music notes` list `gone` as `pixel notes` does: what was said about a sound or effect the bank no longer has, a song's review with no song beside it, or a plain audio file's review whose file is gone, with where to move or remove it; every summary counts them. An approval with no record of what was seen (from an older version, or edited by hand) is `unrecorded: true`, its `next` says to ask the person to look again, and it is no longer counted among the approved in any tool. -- **Limits.** Pictures are read up to 4096 × 4096 pixels in all and 16384 on a side; a header claiming more is refused before decoding instead of running for minutes. `pixel bake` draws at most 1024 frames of at most 1024 art pixels on a side, and a cylinder has at most 256 `sides`. In the CLI, an fx draft's `draw` that runs past 5 seconds on a frame is stopped and is an error: "draw did not return within 5 s on frame F" (`fx check` and `fx look` exit 3). An unexpected throw prints one line, ` failed: `, with no stack, and exits 1. - -**Breaking:** these are refused now where they were taken before. Each says what to change: - -- A key `pixel.config.json`, a pixel recipe, a sound bank or recipe, an fx bank, or a song does not know: `unknown key maxColours: did you mean maxColors?`, exit 2. Rename or remove the key. `pixel.config.json` also lists every problem now, not the first. -- An fx bank's draft `file` or `standIn.file` that is absolute or has a `..` in it. Keep the bank in a folder that holds its drafts and art: - - ```diff - -fx/game.fx.json { "standIn": { "file": "../public/art/hero.png", ... } } - +game.fx.json { "standIn": { "file": "public/art/hero.png", ... } } - ``` - -- A number flag outside its range, which some commands clamped or took: `--count` 1 to 12 (it was clamped), `--sequence` 0 to 10, `--cleanup` 0 to 64, `--shrink-to`, `--width`, and `--height` 1 to 16384, `--scale` 1 to 32, `--colors` 1 to 256, `--columns` 1 to 1024 (`fx look`: 1 to 64), `--spacing` 0 to 64, `--strength` 1 to 400, `--fps` 0.1 to 120, `--tempo` 30 to 300, and `--port` 1 to 65535. The usage error names the flag, the value, and the range (exit 2); pass a value inside it. `pixel sheet --boxes` takes only boxes inside the picture, and `--skip` only frames the sheet has. diff --git a/.changeset/transition-requests.md b/.changeset/transition-requests.md deleted file mode 100644 index b336a02e..00000000 --- a/.changeset/transition-requests.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@typeonce/ritmo": minor ---- - -Two changes to how `applyStateTransitions(...)` runs transition schedules: - -- **A request queued after `cancelPending()` in a transition schedule takes the cancelled one's place at this marker.** A system in one machine's `onEnter`/`onExit`/`onTransition` that cancelled another machine's request, queued before the marker, and then queued a new one, had the cancelled request commit anyway and the new one wait for the next marker. Now the first request queued after the cancel (`set`, `setIfChanged`, or `transition`) commits at this marker and the cancelled one never does, when the marker has not reached that machine yet: - - ```ts - // Flow's onEnter system, while Music -> Quiet is queued for this marker - nextMachines.music.cancelPending() - nextMachines.music.set({ _tag: "Off" }) // now: Off at this marker, never Quiet (was: Quiet now, Off at the next marker) - ``` - - A `set` without the cancel keeps the old rule (the queued value commits now, the new one at the next marker), and so does any request for a machine the marker already applied. Game code that worked around this by ticking a second marker can drop it. - -- **A failing `onEnter` schedule no longer skips the others.** When one enter schedule of a state returned a failure, the other enter schedules of that state, and those of the sub-states that start existing with it, never ran, on that tick or any later one. Each enter schedule now runs in its usual order whatever the ones before it returned; the tick still returns the first failure, the transition is still committed, and it is still traced as `enterFailed`. Exit and `onTransition` schedules keep their rule: a failure there undoes the exit phase and queues the transition again. diff --git a/.changeset/widened-entries-and-set-raw.md b/.changeset/widened-entries-and-set-raw.md deleted file mode 100644 index 0e3b4e9c..00000000 --- a/.changeset/widened-entries-and-set-raw.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@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/browser/CHANGELOG.md b/packages/browser/CHANGELOG.md index bf07de49..964e9056 100644 --- a/packages/browser/CHANGELOG.md +++ b/packages/browser/CHANGELOG.md @@ -1,5 +1,80 @@ # @typeonce/ritmo-browser +## 0.23.0 + +### Minor Changes + +- 0b70fbc: **Breaking: a gamepad without the standard mapping is read by its buttons only.** Its axes cannot be mapped (many such pads report their triggers as axes resting at -1), so before, nobody touching an Xbox 360 pad on such a browser read as `lastUsed: "gamepad"` with the right stick pinned left, and `join: "any"` seated it by itself. For a pad whose `mapping` is not `"standard"`, the sticks and analog triggers now read as rest (`left`/`right` `{ x: 0, y: 0 }`, vector actions 0, `leftTrigger`/`rightTrigger` 0) and its axes never count toward `used`, `lastUsed`, or a join. Its buttons are read by index as before, a press still counts as use, and an action bound to `"rightTrigger"` follows the button at that index. The snapshot gains `standard: boolean`, `false` for such a pad (`true` for a standard pad, with no pad, and for `scripted` pads), so a game can tell the player the sticks are not supported and offer button bindings: + + ```ts + const pad = input.snapshot().devices.gamepad + if (pad.connected && !pad.standard) showNotice("This controller's sticks are not supported: bind its buttons in Controls") + ``` + + A `Gamepad.Snapshot` built by hand needs the new field; `Gamepad.idle(bindings)` has it: + + ```diff + -const neutral = { ...rest, used: false, connected: false, id: null } + +const neutral = { ...rest, used: false, standard: true, connected: false, id: null } + ``` + + `Pointer.actions` releases the pointer capture whenever it lets go of every button: a window blur, a `pointercancel`, leaving the pointer lock, and `dispose()` now call `releasePointerCapture` for the pointer the first press captured, where before only the last button's `pointerup` did. A pointer the browser no longer captures (after a cancel) is skipped instead of throwing. + + `Keyboard.actions` treats keys that type the same character as one key for a character binding. `run: ["Shift"]` stays held while either Shift key is down, with one press when the first goes down and one release when the last comes up; the same holds for both Ctrl and Alt keys and for a digit on the row and on the numpad. Before, letting go of left Shift released `run` while right Shift was still down. Bind `Keyboard.code("ShiftLeft")` to tell them apart. A host that sends no `code` behaves as before. + + A new press of a key whose keyup the page never saw (and no blur or Cmd release cleared) now matches its release by the character it types now, and lets go of the one it typed before. Before, W pressed again as "∑" with Option held kept the old "w" for its release, so an action bound to "∑" stayed held until a blur. Repeats are unchanged: a repeat presses a key only if it is neither held nor known to be down. +- a05002e: **Breaking: `Keyboard.actions` leaves browser shortcuts to the browser.** A key pressed with Cmd, or with Ctrl, is no longer `preventDefault`-ed and presses no action, so Ctrl+R / Cmd+R reload the page instead of firing a `restart` bound to `"r"`, and Ctrl+F, Ctrl+L, and Cmd+A work on bound keys. AltGr reports Ctrl and Alt together and types characters on many layouts, so a key pressed with both still presses its action. A game that binds Control or Meta to an action (`"Control"`, `"Meta"`, `Keyboard.code("ControlLeft")`, ...) keeps every key pressed with them, as before, for as long as its bindings hold one. `Keyboard.presses(...)` skips shortcuts too, so Ctrl+R on a settings screen binds nothing; Control pressed alone is still offered. + + `KeyEvent` gains optional `ctrlKey`, `metaKey`, and `altKey`, which a real `KeyboardEvent` has. A test host that sends none behaves as before. + + Two fixes for keys whose release the page never sees. macOS sends no keyup for a key released while Cmd is down, so the action stayed held until a blur: Cmd's own keyup now lets go of every held key but the modifiers. And a key still down after that, or after a window blur, is pressed again at its next auto-repeat, where before it stayed released until pressed again. A repeat of a held key is still no new press, and neither is one of a key that went down in a form field, as a shortcut, or before a rebind bound it. + + A game whose players press bound keys with Ctrl held (a sneak or modifier key on Ctrl, read alongside other actions) binds Control as an action, which keeps every key pressed with it the game's: + + ```diff + -const keys = { jump: [" "], fire: ["f"] } as const + +const keys = { jump: [" "], fire: ["f"], sneak: ["Control"] } as const + ``` + +### Patch Changes + +- 51a0b7f: The `BrowserTickSource` example writes its spawn entry as a `[Descriptor, value]` tuple, since core removed `Game.Command.entry`. +- dfe3432: A debug runtime freezes what it is handed about twice as fast. The freeze walks arrays by index and objects by their enumerable keys and own symbols, and no longer calls `Reflect.ownKeys` on every value: in a top-down shooter's crowd scene a debug step went from 0.40 ms to 0.25 ms (0.18 ms without `debug`), and in a stress scene of 2000 projectiles from 13.5 ms to 7.5 ms. What is frozen is unchanged for plain data; an own non-enumerable property is no longer walked. + + The docs say what `debug` costs: it freezes every value written to the world, with or without a listener. The `BrowserHostClock` example attaches the handle in development only (`import.meta.env.DEV ? { hostClock } : undefined`), and `Debug.Option` notes that the clock times trace events only, so a headless runtime shared by tests and long bot runs takes `debug` as a choice. +- 9ccc12b: `Pointer.actions` sees mouse chords. Browsers send `pointerdown` only for the first button down and `pointerup` only for the last one up; a button pressed or released while another is held arrives as a `pointermove`, which the pointer ignored. A left click while aiming with the right button was never seen, and the first button let go during a chord stayed held until a window blur. `PointerEventLike` now has an optional `buttons` (the `PointerEvent.buttons` bitmask), and every down, up, and move that carries it holds exactly those buttons, with the same press and release edges as a down or up (a tap of the second button shorter than a frame reports `pressed` and `released` once). The click that takes the pointer lock is still no press, and neither is any button that goes down while the pointer is not locked, until it comes up. A pen's barrel button is `secondary`, as before; its eraser presses nothing. Browsers always send `buttons`, so a game needs no change; a host that sends no `buttons` behaves as before. The pointer now takes capture on the first press only. +- 6704ce1: The `Controls` and `InputCapture` examples declare their game's root and pass it to `Schema.bind`, which now takes one. +- Updated dependencies [5a16a40] +- Updated dependencies [148ec5f] +- Updated dependencies [4df0496] +- Updated dependencies [4df0496] +- Updated dependencies [0dd0a84] +- Updated dependencies [51a0b7f] +- Updated dependencies [dff932a] +- Updated dependencies [dfe3432] +- Updated dependencies [52d6a6f] +- Updated dependencies [02ca2a4] +- Updated dependencies [7e416f5] +- Updated dependencies [40f60e7] +- Updated dependencies [51efba5] +- Updated dependencies [5f9fe33] +- Updated dependencies [1b83e0c] +- Updated dependencies [ca0544d] +- Updated dependencies [4a32ad1] +- Updated dependencies [917001b] +- Updated dependencies [0f4a5fe] +- Updated dependencies [4df0496] +- Updated dependencies [02b8683] +- Updated dependencies [791517f] +- Updated dependencies [4fc0728] +- Updated dependencies [207e611] +- Updated dependencies [d2970e8] +- Updated dependencies [c99b968] +- Updated dependencies [08c0c91] +- Updated dependencies [78683e7] +- Updated dependencies [04d2a9d] + - @typeonce/ritmo@0.23.0 + ## 0.22.0 ### Patch Changes diff --git a/packages/browser/package.json b/packages/browser/package.json index 1f32f4a6..180b3c00 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-browser", - "version": "0.22.0", + "version": "0.23.0", "description": "Browser fixed-step loop, keyboard, gamepad, pointer, and touch actions, merged input, local multiplayer, and input capture for Ritmo", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 4ff622f9..2c04949a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,385 @@ # @typeonce/ritmo +## 0.23.0 + +### Minor Changes + +- 4df0496: **Breaking: `Clock.durationOfSteps` takes a literal count only; a count from data goes through the new `Clock.durationOfStepsRaw`.** A computed count (a `number`) compiled and threw at runtime for 0, a negative, or a fraction ("Clock.durationOfSteps needs a positive whole number of steps, got 0"). It is now a compile error naming the fix (`Clock.durationOfSteps needs a literal count of steps: use Clock.durationOfStepsRaw for data`), as `Clock.duration` already did for seconds, and `Clock.durationOfStepsRaw(step, n)` returns a `Result` that fails with `InvalidDuration`, the error `Clock.durationRaw` returns (its `seconds` is the count divided by the rate): + + ```diff + -const reload = Clock.durationOfSteps(step, config.reloadSteps) + +const reload = Clock.durationOfStepsRaw(step, config.reloadSteps) + +if (!reload.ok) return reload + ``` + + 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. Replays and fuzz cases recorded with one of these durations diverge: record them again. +- 0dd0a84: **Breaking: `Entity.makeEntityId`, `Entity.makeHandle`, and `Entity.draft` are no longer exported.** They were the runtime's own constructors and checked nothing: `commands.spawn(Entity.draft(Entity.makeEntityId(7), { "Game/Position": { x: 1, y: 2 } }))` compiled under `spawns(Position)` and spawned an entity with no components while its type said it held `Position`. Use the checked ways instead: + + ```diff + -commands.spawn(Entity.draft(Entity.makeEntityId(7), { "Game/Position": { x: 1, y: 2 } })) + +commands.spawn(Game.Command.spawn([Position, { x: 1, y: 2 }])) + + -const handle = Entity.makeHandle(entity.id) + +const handle = Game.Entity.handle(entity) + + -// a number read from a save or a network message + -const id = Entity.makeEntityId(raw) + +if (Entity.isHandle(Root)(raw)) { + + const id = Game.Entity.id(raw) // still proves nothing: resolve it with queries.x.get(id) + +} + ``` +- 51a0b7f: **Breaking: a `[Descriptor, value]` entry is checked against its own component, and `Game.Command.entry` is removed.** A tuple entry of `Game.Command.spawn(...)`, `Game.Command.extend(...)`, or `commands.insert(...)` has an inferred value, so TypeScript's excess property check never reached it: `commands.insert(entity, [Position, { x: 0, y: 0, zzz: 1 }])` compiled and stored `zzz`. Its other errors were checked against every component of the schema at once and named an unrelated one (`'"Game/Health"' is not assignable to type '"Game/Velocity"'`). Each entry is now checked on its own, and the error is on that entry: + + | Entry | Message now | + | --- | --- | + | `[Position, { x: 1, y: 2, z: 3 }]` | `Type '{ x: number; y: number; z: number; }' is not assignable to type '"Game/Position has no field z: pass only its own fields"'` | + | `[Position, { x: 1 }]` | `Type '{ x: number; }' is not assignable to type '{ readonly x: number; readonly y: number; } & ValueOf<"Game/Position">'` / `Property 'y' is missing ...` | + | `[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 + -commands.insert(entity, Game.Command.entry(Kind, "pickup")) + +commands.insert(entity, [Kind, "pickup"]) + Game.Command.spawn( + Game.Command.entryRaw(Position, { x, y }), + - Game.Command.entry(Wall, {}) + + [Wall, {}] + ) + ``` + + **Breaking: an initial resource in `Game.Runtime.make({ resources })` with a field the resource does not have no longer compiles.** `resources: { Config: { speed: 1, bogus: 2 } }` compiled and stored `bogus`, which then failed to restore from a save against a strict schema. It now fails with `Game/Config has no field bogus: pass only its own fields` (for a constructed resource, against its raw input). Drop the field. + + ```diff + -Game.Runtime.make({ services, resources: { Config: { speed: 1, bogus: 2 } } }) + +Game.Runtime.make({ services, resources: { Config: { speed: 1 } } }) + ``` +- dff932a: **Breaking: an `update` callback that returns a field the value does not have no longer compiles, spread or not.** A spread in a callback's result escapes TypeScript's excess property check, so `data.position.update(entity, (p) => ({ ...p, xx: p.x + 1 }))` compiled, stored `xx` next to `x` and `y`, and a save holding it then failed to restore against a strict schema (`InvalidComponent ... Unrecognized key: "xx"`). Component and resource `update`, and `updateRaw` on constructed descriptors, now fail with a message naming the descriptor and the field: + + ``` + Game/Position has no field xx: return only its own fields + ``` + + Fix the field name, or drop the field. Values with an index signature (`Record`), arrays, tuples, primitives, `Map`s, `Set`s, and unions of object shapes (any field of any member) are accepted as before. `WriteCell` and `ConstructedWriteCell` take the resource's name as a last, optional type parameter. A helper generic over the value type (`(slot: WriteSlot) => slot.update(entity, (v) => v)`) cannot be checked and no longer compiles: write the result there with `slot.set(entity, next(slot.get(entity)))`. + + ```diff + -data.position.update(entity, (p) => ({ ...p, xx: p.x + 1 })) + +data.position.update(entity, (p) => ({ ...p, x: p.x + 1 })) + ``` +- 52d6a6f: Four more mistakes now fail with a message that names the cause and the fix. Programs that compiled before still compile. + + | Mistake | Message now | + | --- | --- | + | An entity where an id goes: `queries.x.get(entity)`, `queries.x.children`/`descendants`, `lookup.related(entity, R)` (and every `lookup` method), `commands.relate(entity, R, target)`, `unrelate`, `reorderChildren` | `this takes an entity id, not an entity: pass entity.id` | + | A transition schedule (`Game.Schedule.onEnter`, `onExit`, `onTransition`) passed to `runtime.tick`, `tryTick`, `checkpoint`, `debug.beginFrame`, or `Game.Schedule(...)` | `onEnter, onExit, and onTransition schedules run when their machine changes state: bundle them with Game.Schedule.transitions(...) and pass that to Game.Schedule.applyStateTransitions(...) in a schedule you tick` | + | A schedule needing resources, services, or machines the runtime was not made with, at `runtime.tick` (and `runtime.inspect`, `Netcode`'s `simulate`, devtools invariants and labels) | `the runtime is missing resource Game/Score, service Game/Audio, machine Game/Flow: add to Game.Runtime.make({ resources: { Key: value }, services: Game.Runtime.services(Game.Runtime.service(Descriptor, implementation)), machines: Game.Runtime.machines(Game.Runtime.machine(Machine, initial)) })`, every one named, with the fix for each kind that is missing | + | `services: {}` (or a hand-written object) in `Game.Runtime.make` | `services are built with Game.Runtime.services(Game.Runtime.service(Descriptor, implementation)), or Game.Runtime.services() when there are none` | + | `machines: {}` in `Game.Runtime.make` | `machines are started with Game.Runtime.machines(Game.Runtime.machine(Machine, initial)), or left out when there are none` | + + `insert`, `remove`, and `despawn` still take the entity, since they check what it proves. The id parameters have the new type `Entity.IdParameter`: an id, plus a member no value has, which carries the message. + + The requirements message used to be a union of `{ __runtimeRequirementError__, __requirement__ }` objects under `__fixRuntimeRequirements__`, which the compiler cut after two or three. It is now the name of the one property that `Runtime.ValidateScheduleArray` and `Runtime.ValidateInspector` add, since the compiler prints a missing property's name whole. A package that read `__fixRuntimeRequirements__` out of either type intersects with the type itself instead: + + ```diff + -type Errors = Runtime.ValidateInspector extends { readonly __fixRuntimeRequirements__: infer E } ? E : never + -export type Validate = [Errors] extends [never] ? unknown : { readonly __fixMine__: Errors } + +export type Validate = Runtime.ValidateInspector + ``` +- 02ca2a4: Eight common mistakes now fail with a message that names the cause and the fix, instead of a bare type mismatch. Programs that compiled before still compile. + + | Mistake | Message now | + | --- | --- | + | `({ commands }) => commands.spawn(draft)` or `async () => {}` as a system body | `a system returns nothing or Result.failure(...), and runs synchronously: wrap an expression body in braces, and drop async` | + | `Schema.fragment({ relations: { ChildOf } })` with the pair `Descriptor.Hierarchy(...)` returned | `ChildOf is the pair Descriptor.Hierarchy(...) returns: register its relation, ChildOf.relation` | + | `Game.System.relates(Targets)` with the pair | `Game/Targets is passed as the pair Descriptor.Hierarchy/Relation(...) returns: pass the relation itself, the pair's .relation` | + | `querys: {}` next to other keys of a system spec | `querys is not a system spec key: use queries, resources, events, ...` (every category) | + | `Game.Query.write(...)` in an inspector's query | `an inspector only reads: select with Game.Query.read (or optional) instead of Game.Query.write` | + | An entity of another query in a slot of `data` | `Property '"proves component Game/Position"' is missing in type '{ readonly "proves component Game/Health": true; }'` | + | `Schema.fragment({ components: [Position] })` | `components takes a record keyed by name, not an array: components: { Position, Health }` | + | `Descriptor.Component("Game/Name")` (and `Resource`, `Event`, `Service`, `TransientComponent`, `TransientResource`) | `the name goes in the second call: Descriptor.Component()(name)` | + + `Game.System` takes its body as `System.SystemBody` (a `SystemRun` whose return type also carries the message); a system's `run` is still a `SystemRun`. +- 7e416f5: **Breaking (debug runtimes only): a debug runtime checks each entity a query's `data` is given, and throws for one that matches none of the system's queries.** An entity kept from an earlier run (in `local` state or a closure) went on reading after it left its query (gained a `without` component, lost a `with` one, or a relation filter stopped matching), so a homing shot kept chasing a target that had died. Now, on a runtime made with `debug`, the read throws: + + ``` + Game/Homing used Game/Position of entity 12, which matches none of its queries: an entity kept from an earlier run is not checked against its query again. Keep entity.id and resolve it with queries.x.get(id) in each run, which is fallible + ``` + + An entity from another query of the same system still works, as before: the check passes when any of the system's queries matches. A despawned entity, and one that lost the slot's component, keep their own errors. Runtimes without `debug` read the entity as before. The check costs a few nanoseconds per slot call on a debug runtime (about 0.03 ms of a 0.30 ms debug step in a top-down shooter's crowd scene). + + If your tests or devtools sessions now throw, keep the entity's id and resolve it in each run: + + ```diff + -local.target ??= queries.targets.each()[0] + -aimAt(queries.targets.data.position.get(local.target)) + +local.target ??= queries.targets.each()[0]?.id + +const found = local.target === undefined ? undefined : queries.targets.get(local.target) + +if (found?.ok) aimAt(queries.targets.data.position.get(found.value)) + ``` +- 40f60e7: **Breaking: a descriptor made with a name typed `string` is no longer a descriptor.** A helper typed `(name: string) => Descriptor.Component()(name)` gave every descriptor it made one type, so the compiler accepted one for another (an entity of a query over one in a slot of the other, a despawn under the other's permission), and the mix-up surfaced at runtime as "An entity was used with the data of ... of a runtime it does not belong to". Every constructor that takes a name (`Descriptor.Component`, `ConstructedComponent`, `TransientComponent`, `Tag`, `EntityState`, `Resource`, `ConstructedResource`, `TransientResource`, `Event`, `Service`, `Hierarchy`, `Relation`, `Game.StateMachine`, `Game.EntityScope`, `EntityScope.make`, and `Clock.resource`) now returns, for a `string` name, a value no API takes, and the first place it is used fails with: + + ``` + a descriptor's name is a literal type: pass the name as a literal or `as const`, or make the helper generic over it, (name: Name) + ``` + + Make the helper generic over the name: + + ```diff + -const counter = (name: string) => Descriptor.Component()(name) + +const counter = (name: Name) => Descriptor.Component()(name) + ``` + + Literal names (`Descriptor.Component()("Game/Health")`), names `as const`, and template literal types (`` `Game/${string}` ``) work as before. +- 51efba5: State machines take the same transition graph and compare-and-set move as `Descriptor.EntityState`, so both kinds of state are written one way. `Game.StateMachine(name, states, { transitions })` declares, for every state, the states it may move to (`[]` for one with no way out), alone or next to `within`. `nextMachines.x.transition({ from, to, data })` queues the move with all of `to`'s fields only while the machine's committed state is `from`; otherwise it queues nothing and returns a `MachineStateMismatch` failure (`SubStateInactive` for a sub-state whose parent is elsewhere). A `to` the graph does not allow from `from`, an unknown state, and missing or extra fields do not compile. Without a graph any pair of states is accepted. The queued move commits at `applyStateTransitions(...)` like `set`, and only while the machine is still in `from`: a move queued from a transition schedule while the machine is leaving `from` is dropped at the following marker (traced as a `stale` transition, a new `Debug.TransitionEvent` outcome). A machine that declares `transitions` moves only with `transition`: `set` and `setIfChanged` do not compile on it, with an error naming `transition`, so every move it makes is one the graph lists. For a move from whatever state it is in, pass the state read from `machines.x.get()._tag` as `from`: with a union `from`, `to` must be a move every one of them allows. A machine without `transitions` keeps `set` and `setIfChanged` as they were. + + ```ts + const Round = Game.StateMachine("Game/Round", { + Lobby: {}, + Playing: z.object({ round: z.int() }), + Over: z.object({ winner: z.int() }) + }, { transitions: { Lobby: ["Playing"], Playing: ["Over"], Over: ["Lobby"] } }) + ``` + + ```diff + -nextMachines.round.set({ _tag: "Over", winner: 0 }) + +nextMachines.round.transition({ from: "Playing", to: "Over", data: { winner: 0 } }) + ``` + + Breaking: + + - An entity state declared with `transitions` has no `set`, like a machine: move it with `data.x.transition(entity, { from, to, data })`, or change the current state's fields with `update`. `setRaw` stays, for data from outside the simulation. An entity state without `transitions` keeps `set`. + - `nextMachines.x.reset()` is `nextMachines.x.cancelPending()`: it drops the queued value, next to `getPending()`; it never reset the machine. + - `Game.System.transition(M)` is `Game.System.readTransition(M)` (and `System.transition` is `System.readTransition`), like `readTransitionEvent`: it declares read access to the transition being applied, and `transition` now names the move. + - `Machine.MachineStateMismatch` is `Machine.MachineStateMismatchError` and `Machine.SubStateInactive` is `Machine.SubStateInactiveError`, like `EntityStateMismatchError`. The `_tag` values are unchanged. + - `Machine.UpdateResult` is `Machine.CompareAndSetResult`: it is what both `machines.x.update(...)` and `nextMachines.x.transition(...)` return. + - `Descriptor.EntityStateTransitions` is `Descriptor.StateTransitions`: it is the graph of both kinds of state. + - `Machine.StateMachine(...)`, `Machine.read`, `Machine.update`, `Machine.write`, `Machine.transition`, and `Machine.readTransitionEvent` are removed: they duplicated `Game.StateMachine(...)` and `Game.System.machine` / `updateMachine` / `nextState` / `readTransition` / `readTransitionEvent` under other names, and nothing else called them. + + ```diff + -data.contact.set(entity, { _tag: "air", vy: 2, coyote: 0 }) + +data.contact.transition(entity, { from: "ground", to: "air", data: { vy: 2, coyote: 0 } }) + -nextMachines.round.reset() + +nextMachines.round.cancelPending() + -transitions: { round: Game.System.transition(Round) } + +transitions: { round: Game.System.readTransition(Round) } + -type Failed = Machine.MachineStateMismatch<"Lobby" | "Playing"> + +type Failed = Machine.MachineStateMismatchError<"Lobby" | "Playing"> + -type Updated = Machine.UpdateResult + +type Updated = Machine.CompareAndSetResult + -type Moves = Descriptor.EntityStateTransitions + +type Moves = Descriptor.StateTransitions + ``` + + A `transitions` key that is not a state now fails with a message naming the fix (`this key is not a state: ...`) instead of `not assignable to type 'never'`, for entity states too, and `machines.x.update(...)`'s extra-field error names `nextMachines.x.transition(...)` as the way to move. The `ritmo` skill tells games to declare the graph and move with `transition`. +- ca0544d: **Breaking: `lookup.parent`, `Result.isSuccess`, and `Result.isFailure` are removed; each had another name for the same job.** `lookup.parent(id, R)` was `lookup.related(id, R)` for a hierarchy, and `result.ok` already narrows a `Result`. `lookup`'s methods now say in their JSDoc what each one is for and which query slot or query handle method to use instead. + + ```diff + -const hand = lookup.parent(item.id, ChildOf) + +const hand = lookup.related(item.id, ChildOf) + + -if (Result.isSuccess(made)) use(made.value) + -if (Result.isFailure(made)) report(made.error) + +if (made.ok) use(made.value) + +if (!made.ok) report(made.error) + ``` +- 4a32ad1: **Breaking: `pauseWhen` is now `pauseWhile`, and it pauses while a condition holds.** `Game.Schedule.pauseWhen(conditions, ...)` and a system spec's `pauseWhen: conditions` paused the systems while a condition *failed*, the same polarity as `when`, so the name read backwards: `pauseWhen([Game.Condition.inState(HitStop, "Frozen")], Move)` ran `Move` only during the freeze, with no error. `Game.Schedule.pauseWhile(conditions, ...)` and the spec key `pauseWhile` pause while *any* of their conditions holds (`pauseWhile([frozen, cutscene])` pauses during either). `when` keeps its meaning (runs while all hold), so the two read together: `{ when: [playing], pauseWhile: [frozen] }` runs when playing and pauses while frozen. Everything else about pausing is unchanged: a paused reader holds its stream entries, marker steps in the group still run, and `when` and pauses nest as before. + + `pauseWhen` no longer compiles, so no call keeps its old meaning silently. Rename it and invert each condition: drop a `Game.Condition.not(...)` the old call needed, and add one where it had none. A list `pauseWhen([a, b])` (paused while `a` or `b` failed) becomes `pauseWhile([Game.Condition.not(a), Game.Condition.not(b)])`. + + ```diff + const frozen = Game.Condition.inState(HitStop, "Frozen") + -Game.Schedule.pauseWhen([Game.Condition.not(frozen)], Move, ApplyHits) + +Game.Schedule.pauseWhile([frozen], Move, ApplyHits) + -Game.System("Game/Read", { events, pauseWhen: [ready] }, run) + +Game.System("Game/Read", { events, pauseWhile: [Game.Condition.not(ready)] }, run) + ``` + + Debug output follows the name: `SystemDescription.pauseWhen` is `pauseWhile`, the `stream-loss-unread` lint says `under pauseWhile`, and a `system.skipped` event with `held: true` carries the `pauseWhile` condition that held. +- 0f4a5fe: `queries.x.rows()` reads every entity of a query into plain values: each row is `{ id, values }`, with one field per slot of the query's `selection` (`undefined` for an optional slot whose entity lacks the component), in `each()` order and with the query's filters applied. The values stay readable after the run, so a view layer, an inspector's result, a memoized helper, or a test can keep them, where a kept `queries.x.data` throws. + + ```ts + const BuildingView = Game.Inspector("Town/BuildingView", { queries: { buildings: BuildingRead } }, ({ queries }) => + queries.buildings.rows()) + + for (const { id, values } of runtime.inspect(BuildingView)) panel.show(id, values.tile, values.site?.progress) + ``` + + `QueryValues` and `QueryRow` (from `@typeonce/ritmo/Query`) type them. A row carries the entity's `id`, not the entity, so it cannot be read through `data` later: resolve it with `queries.x.get(id)` to act on the entity again. `rows()` reads every slot of every entity and makes an object per row (about 50 ns per entity with two slots in the new `query/rows-10k` bench case, and about 110 ns with five, against about 20 ns for five slots read through `each()` and `data`), so systems keep reading `data`. + + A game that wrote its own rows helper can replace it: + + ```diff + -type Row = { readonly entity: QueryEntity; readonly data: { readonly [K in keyof QueryData]: ReturnType[K]["get"]> } } + -const rowsOf = (query) => query.each().map((entity) => ({ entity, data: /* every slot's get(entity), with casts */ })) + -const buildings = rowsOf(queries.buildings) + +const buildings = queries.buildings.rows() // ReadonlyArray> + ``` +- 791517f: `runtime.rewind` is traced: `debug.observe` and `debug.observeHost` deliver a `Debug.RewindEvent` (`{ type: "rewind", frame, tick, toTick, ok }`, ticks being change ticks), so tools can tell a rollback's ticks run again from new ones. `observeHost`'s `tick.start` carries the change tick it starts at (`tick`): a rewind undid the ticks that started at or after its `toTick`. + + **Breaking for host listeners that narrow by elimination.** `Debug.HostEvent` gained a member with no `schedules`, so a listener that rules out `send` and `failure.ignored` and then reads `event.schedules` no longer compiles. Handle the rewind first: + + ```diff + runtime.debug.observeHost((event) => { + if (event.type === "send" || event.type === "failure.ignored") return + + if (event.type === "rewind") return + if (!event.schedules.includes(simulate)) return + ``` + + A `switch` over `Debug.TraceEvent` that checks it is exhaustive needs a `case "rewind"`. +- 4fc0728: **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`). + + Types a game spelled with the schema in the root's place name the root instead: + + ```diff + -type Id = Entity.EntityId + +type Id = Entity.EntityId + ``` + + 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` 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) + ``` +- d2970e8: A load leaves nothing of the world it replaced, and a save holds the work still queued, so the same save gives the same world in every runtime that loads it (every peer of a netcode match). + + 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 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: + + ```ts + if (despawned.entities.lagged()) rebuildFrom(queries.alive.each()) + ``` + + - `runtime.snapshot()` and `runtime.checksum()` fail with `{ _tag: "QueuedCommands", count }` while a command waits for a marker: a command is a function, which no save can hold, so the loaded world ran differently from the one that wrote it. End the schedule the save follows with its marker: + + ```diff + -const update = Game.Schedule(Input, Move, Spawn) + +const update = Game.Schedule(Input, Move, Spawn, Game.Schedule.applyDeferred()) + ``` + + - `Snapshot.WorldSnapshot` has a `nextStates` field: the next states queued for the following `applyStateTransitions`, by machine name (`{ state, ifChanged, from? }`, a `Snapshot.NextStateSnapshot`). `snapshot()` writes it, `checksum()` covers it, and `restore` validates it (`InvalidNextState`, or `InactiveSubState` for a queued sub-state whose parent is elsewhere) and queues it again, so the next marker applies it as in the runtime that wrote the save. A save written before has none and loads with nothing queued, so `Snapshot.version` stays 3. Code that builds a `WorldSnapshot` by hand (a test fixture, a migration that returns a new object) adds the field: + + ```diff + -const save: Snapshot.WorldSnapshot = { version: 3, gameVersion: 0, nextEntity: 1, entities: [], relations: {}, resources: {}, machines: {}, scopes: {} } + +const save: Snapshot.WorldSnapshot = { version: 3, gameVersion: 0, nextEntity: 1, entities: [], relations: {}, resources: {}, machines: {}, nextStates: {}, scopes: {} } + ``` + + 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. + + The `ritmo` skill's state playbook lists both breaking changes for an upgrade. +- 08c0c91: A system body that returns failures of different shapes now compiles, and the system fails with their union. Only failures TypeScript could merge into one type compiled before (two string literals); a string in one branch and an object in another, or two objects with different fields, did not: + + ```ts + Game.System("Game/Buy", spec, ({ resources }) => { + if (resources.gold.get() < 5) return Result.failure("NotEnoughGold" as const) + if (resources.stock.get() === 0) return Result.failure({ _tag: "OutOfStock" as const }) + }) + // fails with "NotEnoughGold" | { _tag: "OutOfStock" }, and so does runtime.tryTick for its schedule + ``` + + `System.SystemBody`'s second type parameter is now the body's whole return type, not its failure type; `System.BodyFailure` gives the failure type of a return type, and `System.SystemReturnFix` is the message a body that returns a value or a promise names. Game code passes neither, so nothing in it changes. +- 78683e7: Two changes to how `applyStateTransitions(...)` runs transition schedules: + + - **A request queued after `cancelPending()` in a transition schedule takes the cancelled one's place at this marker.** A system in one machine's `onEnter`/`onExit`/`onTransition` that cancelled another machine's request, queued before the marker, and then queued a new one, had the cancelled request commit anyway and the new one wait for the next marker. Now the first request queued after the cancel (`set`, `setIfChanged`, or `transition`) commits at this marker and the cancelled one never does, when the marker has not reached that machine yet: + + ```ts + // Flow's onEnter system, while Music -> Quiet is queued for this marker + nextMachines.music.cancelPending() + nextMachines.music.set({ _tag: "Off" }) // now: Off at this marker, never Quiet (was: Quiet now, Off at the next marker) + ``` + + A `set` without the cancel keeps the old rule (the queued value commits now, the new one at the next marker), and so does any request for a machine the marker already applied. Game code that worked around this by ticking a second marker can drop it. + + - **A failing `onEnter` schedule no longer skips the others.** When one enter schedule of a state returned a failure, the other enter schedules of that state, and those of the sub-states that start existing with it, never ran, on that tick or any later one. Each enter schedule now runs in its usual order whatever the ones before it returned; the tick still returns the first failure, the transition is still committed, and it is still traced as `enterFailed`. Exit and `onTransition` schedules keep their rule: a failure there undoes the exit phase and queues the transition again. + +### Patch Changes + +- 5a16a40: The `Checkpoint` module doc names the one case where a reader outside the rewound schedule sees a component as `added` again (it saw the component, did not run while a later tick removed it, and a rewind brought it back), and what a host does about it: key what it creates per entity by entity id. +- 148ec5f: A held checkpoint keeps only the events, removals, and despawns a rewind can bring back: those published before the newest held checkpoint was taken. A game that took one checkpoint and kept it (a level retry, an undo) kept every event batch and removal record published after it: about 160 MB after 2,000 ticks of one 10,000-number event, now about 0.5 MB. No API change. +- 4df0496: `lookup.descendants(target, relation, { order: "breadth" })` (and a query's `descendants`) overflowed the stack below a parent with more than about 125,000 children; it now lists them. +- dfe3432: A debug runtime freezes what it is handed about twice as fast. The freeze walks arrays by index and objects by their enumerable keys and own symbols, and no longer calls `Reflect.ownKeys` on every value: in a top-down shooter's crowd scene a debug step went from 0.40 ms to 0.25 ms (0.18 ms without `debug`), and in a stress scene of 2000 projectiles from 13.5 ms to 7.5 ms. What is frozen is unchanged for plain data; an own non-enumerable property is no longer walked. + + The docs say what `debug` costs: it freezes every value written to the world, with or without a listener. The `BrowserHostClock` example attaches the handle in development only (`import.meta.env.DEV ? { hostClock } : undefined`), and `Debug.Option` notes that the clock times trace events only, so a headless runtime shared by tests and long bot runs takes `debug` as a choice. +- 5f9fe33: The docs show how to name a game's schema type, so type errors print `QueryEntity` 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, Root).schema + type SchemaOf = typeof schema + export interface GameSchema extends SchemaOf {} + const named: GameSchema = schema + export const Game = Schema.bind(named, Root) + ``` + + A helper that takes a query's entities and its `data` is typed with the existing `QueryIteration` (from `@typeonce/ritmo/System`) and passed `queries.walls`, as the `QueryEntity` and `QueryIteration` docs now say, instead of a hand-written `{ each(): ...; data: ... }` type. + + The 0.21.0 entry now says it replaces 0.17's optional-slot fix, so a game upgrading from 0.16 rewrites `data.x.present` straight to `data.x.has(entity)`. +- 1b83e0c: The `ritmo` skill's netcode playbook says that a reloaded page ends the match (every peer ends with `Disconnected` `reason: "restarted"`, so `onFailure` offers a new match), that a HUD draws who everyone waits for from `status().waiting`, and that the rollback check looks for rollbacks on the peer ahead (the one behind may always have the input in time). +- 917001b: The `ritmo` skill has a performance playbook (`playbooks/performance.md`, listed in `SKILL.md`): measure ticks on a real host clock without `debug`, name the system with `session.report()` on the same clock (a test clock makes every timing 0), fix the usual causes, and compare the same number before and after. + + `SETUP.md` starts with what a game needs: Node 22.19 or newer for the commands the setup and the skills run, and TypeScript 5.9 or newer with `strict` on and `noErrorTruncation` off. + + The spatial grid reference says where the grid lives in a schedule that rewinds, where `local` does not compile: in the system's body, made each run. +- 4df0496: `Random` handles its edge cases, and every value a game drew before is drawn the same (replays and saves are unchanged): + + - `integer(min, maxExclusive)` with a span wider than 2**32 returned only some integers (`integer(0, 2 ** 40)` gave only even numbers): such a span now takes two draws (now and then more) and every integer in it is equally likely. A span up to 2**32 still takes one draw and gives the same sequence. A span past `Number.MAX_SAFE_INTEGER` throws (`Random.integer needs a span of at most Number.MAX_SAFE_INTEGER`), as bad bounds already did. + - `range(min, max)` returned `Infinity` when `max - min` overflowed (`range(-1e308, 1e308)`); it now stays in range. + - `weighted` returned `undefined` when the usable weights added up past the largest number; it now throws (`Random.weighted needs usable weights that add up to a finite total`). +- 02b8683: `runtime.rewind` gives readers what a runtime that never rewound gives them: + + - A system of the rewound schedule whose first run came after the checkpoint (one in an `onEnter`/`onExit` schedule, or after a failing system) is back to never having run. It used to stay registered with its positions at zero, so it held every event, removal, and despawn published after the checkpoint and read them all on its next run, where a runtime that never rewound had dropped them. The frames ticked after a rewind also keep what the frames after the checkpoint kept the first time, so a reader's first run sees the same events either way. + - A system or inspector outside the schedule (a HUD, render sync) sees every resource the rewind put back as changed: `changedResource(...).changed()` was `false` for a value it had already drawn and the rewind undid. + - An entity the rewind took away and the ticks after it brought back (the same id) no longer matches `Game.Query.added(X)` a second time for a reader outside the schedule that had seen `X` on it: it matches `changed(X)`, as the `Checkpoint` module doc says. A component that reader never saw on that id is still `added`. A render system creating a node on `added` created two before. + + No code changes. +- 207e611: Six runtime fixes: + + - **`runtime.restore` loads a large save.** A save of about 125,000 entities or more threw `RangeError: Maximum call stack size exceeded`, after the world had already been emptied and every checkpoint released, although `snapshot()` of the same world succeeded. It now restores, and a failed restore again changes nothing. + - **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.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. +- c99b968: `SETUP.md` step 4 declares the root it binds the game under (`const Root = Schema.defineRoot("Game")`), so the snippet compiles as written. +- 04d2a9d: 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. + ## 0.22.0 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 545a2cbe..4cb2bcac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo", - "version": "0.22.0", + "version": "0.23.0", "description": "The agent-first, type-safe ECS runtime for TypeScript", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index 613357c5..122557af 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -1,5 +1,79 @@ # @typeonce/ritmo-devtools +## 0.23.0 + +### Minor Changes + +- cb56a2f: `ritmo-devtools lint` fails on Ritmo packages installed at different versions. Every package depends on `@typeonce/ritmo` at its own minor, so a game with core 0.23 and browser 0.22 got a second, hidden copy of core 0.22 inside browser, and then type errors between the two copies that named neither. From the working directory, the lint now finds every `@typeonce` package the game resolves and every copy those resolve in turn (npm and yarn trees, nested or hoisted, and pnpm's store), and when they are not one version prints one warning naming each copy and the fix, and exits with 1: + + ``` + warning mismatched-versions @typeonce packages: @typeonce/ritmo-browser 0.22.0 is installed with @typeonce/ritmo 0.23.0 (and a second @typeonce/ritmo 0.22.0 under @typeonce/ritmo-browser): ... + fix: install the same version of every @typeonce package + ``` +- f17f675: A replay whose window starts from a save no longer reports a false divergence. Once a recording outlasts `keep` (or after `Replay.extract`), the file starts from a save of a later tick, and a save does not hold events in flight or what each `added()` / `changed()` reader had already seen, so an ordinary game replayed to other checksums and `play()` said `DIVERGED ...: the simulation is not deterministic, or the code changed since recording`. Neither was true. Such a replay now plays as before (stepping, `why`, invariants) and says `starts from a save at tick 14: checksums are not compared (a save does not hold events in flight or what each reader had already seen)`. A file that starts at tick 0, where the recorder started, is compared as before. `Verify.acrossEngines` and `ritmo-devtools verify` skip the recording's checkpoints for such a file in the same way and say so; they still compare the two Node runs and every engine. + + Breaking: `play().data.diverged` is replaced by `play().data.checkpoints`, which is `{ _tag: "Compared", diverged }` or `{ _tag: "NotCompared", reason }`, so a test can tell "not compared" from "compared and equal": + + ```diff + - expect(played.data.diverged).toEqual([]) + + expect(played.data.checkpoints).toEqual({ _tag: "Compared", diverged: [] }) + ``` + + `Verify.Verified` has `recording: "matched" | "notCompared"`. +- 2e4d02d: Sessions and replays understand rewinds (rollback netcode, client prediction): + + - `why`, `whyResource`, `journal`, and `system` show each `runtime.rewind` as a line between the frames it separates, under every filter: `f20 - rewind to tick 81, undoing f13-f20`. A change whose `before` is not the previous line's `after` has the rewind above it; before, undone ticks read as real history. A filter's `limit` counts the other lines and keeps the rewinds between and after them. `report()` counts them (`Report.rewinds`) and lists the count in its first line. `Format.LineKind` has `"rewind"`, and `Format.rewind(event, undid)` renders one. + - `Replay.recorder` forgets the ticks a rewind undid, so a file recorded on a rollback peer holds each tick as it last ran and replays without diverging. It used to record every run of a tick as a new tick. + + A `switch` over `Format.LineKind` that checks it is exhaustive needs a `case "rewind"`. + +### Patch Changes + +- d2970e8: `Desync.compare` lists the queued next states that differ between two saves (`next state Game/Round`), now that saves hold them. `Fuzz`'s `Unreplayable` and the recorder's "no tick could be saved" error name queued commands as a cause next to a rejected `"~encode"`. No change for games. +- dfe3432: A debug runtime freezes what it is handed about twice as fast. The freeze walks arrays by index and objects by their enumerable keys and own symbols, and no longer calls `Reflect.ownKeys` on every value: in a top-down shooter's crowd scene a debug step went from 0.40 ms to 0.25 ms (0.18 ms without `debug`), and in a stress scene of 2000 projectiles from 13.5 ms to 7.5 ms. What is frozen is unchanged for plain data; an own non-enumerable property is no longer walked. + + The docs say what `debug` costs: it freezes every value written to the world, with or without a listener. The `BrowserHostClock` example attaches the handle in development only (`import.meta.env.DEV ? { hostClock } : undefined`), and `Debug.Option` notes that the clock times trace events only, so a headless runtime shared by tests and long bot runs takes `debug` as a choice. +- 52d6a6f: An invariant or `labels` inspector that reads a resource, service, or machine the runtime does not provide fails with the same message as `runtime.tick`, naming every one and where to provide it: `the runtime is missing resource Game/Limit: add to Game.Runtime.make({ resources: { Key: value } })`. It used to be a cut union under `__fixInvariantRequirements__` or `__fixLabelsRequirements__`. +- f17f675: `Fuzz.run` finds only bugs its replay reproduces. A run used to tick `setup` and carry on with the live world, while its replay starts from a save of that world, so a bug that needed what setup left in flight (an event emitted in setup and read on the first frame) came back with `reproduces: false` and a replay that passed. Each run now loads its own save after `setup`, before the first fuzzed frame, so the run and its replay start from the same world. A game whose setup emits events for the first frame no longer has them in fuzz runs, as it never had them in the replays. +- cb56a2f: `ritmo-devtools lint` notes every skill `@typeonce/ritmo` ships that the project does not link, read from the installed package's `skills` folder. It checked only `ritmo`, `ritmo-prototype`, `ritmo-onboarding`, and `ritmo-polish`, so an unlinked or copied `ritmo-pixel-art`, `ritmo-fx`, `ritmo-sounds`, or `ritmo-music` went unnoticed. The README's replay recorder snippet now imports `ReplayViteSink` and `ReplayVitePlugin` from their subpaths and records with `HostClock.standard`, and its list of `run.data.stop` reasons is complete. +- 4a32ad1: Follows core's `pauseWhile`: a system description lists `pauses while` conditions, and a paused run reads `paused: is true (holding its stream entries)`, since a pause now names the condition that holds. +- Updated dependencies [5a16a40] +- Updated dependencies [148ec5f] +- Updated dependencies [4df0496] +- Updated dependencies [4df0496] +- Updated dependencies [0dd0a84] +- Updated dependencies [51a0b7f] +- Updated dependencies [dff932a] +- Updated dependencies [dfe3432] +- Updated dependencies [52d6a6f] +- Updated dependencies [02ca2a4] +- Updated dependencies [7e416f5] +- Updated dependencies [40f60e7] +- Updated dependencies [51efba5] +- Updated dependencies [5f9fe33] +- Updated dependencies [c07e93e] +- Updated dependencies [c07e93e] +- Updated dependencies [1b83e0c] +- Updated dependencies [d2970e8] +- Updated dependencies [c07e93e] +- Updated dependencies [1b83e0c] +- Updated dependencies [ca0544d] +- Updated dependencies [4a32ad1] +- Updated dependencies [917001b] +- Updated dependencies [0f4a5fe] +- Updated dependencies [4df0496] +- Updated dependencies [02b8683] +- Updated dependencies [791517f] +- Updated dependencies [4fc0728] +- Updated dependencies [207e611] +- Updated dependencies [d2970e8] +- Updated dependencies [c99b968] +- Updated dependencies [08c0c91] +- Updated dependencies [78683e7] +- Updated dependencies [04d2a9d] + - @typeonce/ritmo@0.23.0 + - @typeonce/ritmo-netcode@0.23.0 + ## 0.22.0 ### Patch Changes diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 932d4d2d..2dc88a6b 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-devtools", - "version": "0.22.0", + "version": "0.23.0", "description": "Headless debug sessions, trace history, text reports, replays, and fuzzing for Ritmo runtimes", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/devtools/src/internal/version.ts b/packages/devtools/src/internal/version.ts index 43c50058..48725457 100644 --- a/packages/devtools/src/internal/version.ts +++ b/packages/devtools/src/internal/version.ts @@ -3,4 +3,4 @@ * `package.json` by `scripts/sync-version.ts` (run by \`pnpm version-packages\`) * and checked by a test. */ -export const libraryVersion = "0.22.0" +export const libraryVersion = "0.23.0" diff --git a/packages/math/CHANGELOG.md b/packages/math/CHANGELOG.md index b7396a6f..77815625 100644 --- a/packages/math/CHANGELOG.md +++ b/packages/math/CHANGELOG.md @@ -1,5 +1,52 @@ # @typeonce/ritmo-math +## 0.23.0 + +### Patch Changes + +- 84caa60: `Vec2.moveToward` and `Vec2.clampLength` count a limit below 0 as 0, as `Scalar.moveToward` and `Angle.turnToward` already did. A speed that went negative (a slow effect minus friction) moved the point away from its target, and a negative cap flipped the vector; now `moveToward` returns `current` unchanged and `clampLength` the zero vector. A `NaN` limit counts as 0 too, as in `Scalar.moveToward`. + + `FlowField.distance` gives a distance wherever `way` gives a direction. From a blocked cell (a body pressed against a wall or into a corner) it was `undefined` even where `way` led it out, so a chaser ranking targets by distance skipped it. It is now the distance out along the steps `way` takes: to the open cells it heads for, plus their distance. It is `undefined` exactly where `way` is, except where the body is already there. Every open cell's distance is unchanged. + + `FlowField`'s docs say which wall sizes the field can step through: a cell is blocked by its center alone, so a wall `width` by `height` can fall between open cells exactly when `hypot(max(0, cellSize - width), max(0, cellSize - height)) > 2 * clearance`. No step crosses a wall at least `cellSize - clearance * √2` thick both ways, and none crosses any wall when `cellSize <= clearance * √2`; with thinner walls, use smaller cells or a larger clearance. Behaviour is unchanged. +- 30b6570: `Collide2.moveCircle` no longer passes through walls where they close in on a circle. Pushing off one wall by the skin could leave the circle a hair inside another, and a wall the circle started in was ignored for the rest of the move, so the slide went through it: a circle sliding between a crate and a wall left the room on the next move, and one pushed into an acute corner of segments came out the other side. Now a circle that starts outside every wall ends outside every wall, also in acute corners and gaps narrower than it, and stops there (`blocked`) when nothing is left to slide along. A wall it touches or is inside, while moving into it, is a contact at once; where the skin would push it into another wall, it stays touching instead. + + `Collide2.pushOut` leaves the deepest wall first, leaves both walls of an acute corner at once (pushing out of one alone pushed into the other), and takes a center inside a box out through the nearest side with room, so a circle spawned in a corner or in a crate against a wall comes out clear of every wall. Only walls with no room between them (a gap narrower than the circle) can leave it overlapping. + + `FlowField` finds the way to a goal backed into a corner. When the goal's cell and every cell it could step to are blocked (a player pushed into a room corner, or standing against a wall more than one blocked cell deep), the search starts from the nearest open cells the goal can see in a straight line, where every chaser used to get `undefined`. A body in such a blocked cell gets `way` out to the nearest open cell it can see. Every way the field gave before is unchanged. + + `moveCircle` is also faster: it skips walls outside the bounds of the swept circle before testing them. +- Updated dependencies [5a16a40] +- Updated dependencies [148ec5f] +- Updated dependencies [4df0496] +- Updated dependencies [4df0496] +- Updated dependencies [0dd0a84] +- Updated dependencies [51a0b7f] +- Updated dependencies [dff932a] +- Updated dependencies [dfe3432] +- Updated dependencies [52d6a6f] +- Updated dependencies [02ca2a4] +- Updated dependencies [7e416f5] +- Updated dependencies [40f60e7] +- Updated dependencies [51efba5] +- Updated dependencies [5f9fe33] +- Updated dependencies [1b83e0c] +- Updated dependencies [ca0544d] +- Updated dependencies [4a32ad1] +- Updated dependencies [917001b] +- Updated dependencies [0f4a5fe] +- Updated dependencies [4df0496] +- Updated dependencies [02b8683] +- Updated dependencies [791517f] +- Updated dependencies [4fc0728] +- Updated dependencies [207e611] +- Updated dependencies [d2970e8] +- Updated dependencies [c99b968] +- Updated dependencies [08c0c91] +- Updated dependencies [78683e7] +- Updated dependencies [04d2a9d] + - @typeonce/ritmo@0.23.0 + ## 0.22.0 ### Patch Changes diff --git a/packages/math/package.json b/packages/math/package.json index 87fae83d..b84daa2e 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-math", - "version": "0.22.0", + "version": "0.23.0", "description": "The 2D helpers most Ritmo games would otherwise rewrite (vectors, angles, swept collision, flow field), plain data in and out, built on Ops so they replay the same on every engine", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/netcode/CHANGELOG.md b/packages/netcode/CHANGELOG.md index eed6c690..1a025a61 100644 --- a/packages/netcode/CHANGELOG.md +++ b/packages/netcode/CHANGELOG.md @@ -1,5 +1,87 @@ # @typeonce/ritmo-netcode +## 0.23.0 + +### Minor Changes + +- c07e93e: `EventsLost` has a `reason`: `"unread"` (the reader fell behind, as before) or `"resync"`. A peer one or two ticks ahead of seat 0 can confirm ticks that seat 0's next save makes idle; when the resync runs them again with other events, `lost()` now reports those ticks with `reason: "resync"`, where before the events it had handed out as confirmed stayed in the host's sound or score with no report. The new run's events are still not handed out a second time, and a tick that runs again with the same events (compared by value) reports nothing. `lost()` returns one report per call, a `"resync"` one first. + + Code that builds an `EventsLost` (a test double) adds the field: + + ```diff + -const lost: Netcode.EventsLost = { _tag: "EventsLost", from: 10, to: 12 } + +const lost: Netcode.EventsLost = { _tag: "EventsLost", from: 10, to: 12, reason: "unread" } + ``` +- c07e93e: **Breaking: the local input goes through the input schema, and one the schema rejects throws.** `advance(local)` (and `syncTest`'s `advance`) now encodes `local`, writes it as JSON, and decodes it with the seats' schema, as every other peer does with the message, and this peer simulates the decoded value. Before, the local peer simulated the raw value and the others its decoded form: a schema that normalizes (a clamp, a default, a strip) desynced the match on the first input it changed, and an input the schema rejects (an analog `dx` of `0.5` where the schema wants a whole number; it compiles as `number`) was dropped by every other peer, so the match stalled for good with no error. + + An input the schema rejects now throws from `advance` with `Netcode: the local input does not decode with the input schema: `, like one that does not encode already did. The fix is to hand `advance` the schema's output type, for example: + + ```diff + -session.advance({ dx: stick.x, fire }) + +session.advance({ dx: Math.round(stick.x), fire }) + ``` + + `Netcode.seats` checks `idle` the same way, so an `idle` that does not survive JSON fails there with `InvalidOptions`. +- 1b83e0c: **A seat whose page reloads ends the match instead of deadlocking it.** A new session for a seat already in the match restored seat 0's stored save and waited for inputs seat 0 had pruned, while seat 0 ignored the new session's inputs: both stayed `Stalled` with no timeout. Now every peer ends with `Disconnected` and the new reason `"restarted"`, naming the seat, and so does the new session (at once when seat 0's save reaches it, or after `timeoutMS` of hearing a match that runs without it). There is no way back into the match: `onFailure` shows that the match ended and offers a new one. Sessions are told apart by their `hostClock` reading when made, carried in hellos, pings, and saves. + + **`status().waiting`** names what a stalled session waits for: `{ seat, tick, forMS }` (the seat whose input for `tick` this peer cannot run past, and for how long by `hostClock`), or `undefined` while it runs, waits for a save, or is halted. A HUD draws "waiting for seat 1"; a headless test says why a match stopped. + + Code that matches every `Disconnected` reason, or builds a `Status` (a test double), adds the new member: + + ```diff + switch (failure.reason) { + case "closed": return "the connection closed" + case "timeout": return `seat ${failure.seat} went quiet` + + case "restarted": return `seat ${failure.seat} reloaded: start a new match` + } + -const status: Netcode.Status = { ...fields, delayChanges: 0 } + +const status: Netcode.Status = { ...fields, delayChanges: 0, waiting: undefined } + ``` + + Session bugs over a jittery, lossy link are fixed: + + - Inputs of a newer epoch that overtook their save were taken into the old timelines, with the acknowledgements they carry: a peer could prune its own inputs for ticks the save then ran again and stall for good, or desync after the resync. Inputs (and their acknowledgements and pacing) now apply only in the epoch they were sent in; the sender sends them again. + - A hidden tab announced its hide once, so under loss the others could miss it and end with `Disconnected` after `timeoutMS`. The announcement now carries the inputs the others still need, goes out five times, and every peer that heard it passes it on with its pings while the seat is hidden (with two seats, it is lost only when all five copies are). Its inputs count in a later epoch too, from that save's floor on; one of a newer epoch waits for its save, but the seat counts as hidden at once, so a seat that hides right after a save is not timed out by a peer whose save was lost. + - A message overtaken by a later one lowered the acknowledgements a peer held, so a seat that hid could announce fewer inputs than the others had already run with: a desync with a long input delay (rollback), and after a resync (lockstep). Acknowledgements now only grow. + - A seat back from a hide under `"resync-on-return"` sent checksums of the ticks it ran on guesses while it waited for its save, and the others ended `Desynced`. A seat played around sends no checksums. + + The session protocol is version 3: a peer on an earlier version ends with `ProtocolMismatch`. Give every peer the same release. + +### Patch Changes + +- d2970e8: `SyncFailed`'s doc names `QueuedCommands`: a session whose `simulate` leaves a command queued after its last marker ends with it on the first checksum or save, since `runtime.checksum()` and `runtime.snapshot()` now fail then. End `simulate` with `Game.Schedule.applyDeferred()`. Peers that restore a save no longer keep their own transient resources or report the replaced world's despawns (see `@typeonce/ritmo`), which made a match desync after its first save or after every resync. No code changes. +- c07e93e: A session enforces the sender's own limits on what it receives, so another peer cannot grow its memory without bound: an inputs message with more than 64 inputs, or a checksum longer than 16 characters, is malformed, and inputs or checksums for ticks further ahead than an honest peer can be (more than `2 * (maxRollback + inputDelay) + 64` ticks past this peer's last input, `adaptiveDelay.max` for an adaptive delay) are dropped. Both count in `status().dropped`. An honest match whose peers give the same `maxRollback` and delay drops nothing, through a resync and a seat that hides and returns; a peer given a larger `maxRollback` may see some inputs dropped until they are in reach, and sends them again. +- Updated dependencies [5a16a40] +- Updated dependencies [148ec5f] +- Updated dependencies [4df0496] +- Updated dependencies [4df0496] +- Updated dependencies [0dd0a84] +- Updated dependencies [51a0b7f] +- Updated dependencies [dff932a] +- Updated dependencies [dfe3432] +- Updated dependencies [52d6a6f] +- Updated dependencies [02ca2a4] +- Updated dependencies [7e416f5] +- Updated dependencies [40f60e7] +- Updated dependencies [51efba5] +- Updated dependencies [5f9fe33] +- Updated dependencies [1b83e0c] +- Updated dependencies [ca0544d] +- Updated dependencies [4a32ad1] +- Updated dependencies [917001b] +- Updated dependencies [0f4a5fe] +- Updated dependencies [4df0496] +- Updated dependencies [02b8683] +- Updated dependencies [791517f] +- Updated dependencies [4fc0728] +- Updated dependencies [207e611] +- Updated dependencies [d2970e8] +- Updated dependencies [c99b968] +- Updated dependencies [08c0c91] +- Updated dependencies [78683e7] +- Updated dependencies [04d2a9d] + - @typeonce/ritmo@0.23.0 + ## 0.22.0 ### Patch Changes diff --git a/packages/netcode/package.json b/packages/netcode/package.json index 80042fb2..32e31a6a 100644 --- a/packages/netcode/package.json +++ b/packages/netcode/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-netcode", - "version": "0.22.0", + "version": "0.23.0", "description": "Rollback and lockstep sessions over any transport for Ritmo: input timelines, checkpoint history, pacing, resync, and desync checks", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/oxlint-plugin/CHANGELOG.md b/packages/oxlint-plugin/CHANGELOG.md index 729c1efa..1d2c17fe 100644 --- a/packages/oxlint-plugin/CHANGELOG.md +++ b/packages/oxlint-plugin/CHANGELOG.md @@ -1,5 +1,75 @@ # @typeonce/oxlint-plugin-ritmo +## 0.23.0 + +### Minor Changes + +- 7ac13e9: `ritmo/no-float-timers` knows more names and shapes of a step's length, and reports `x - dt` only where it is written back, so it counts across ticks. + + Newly reported, with the rule's message saying to store a `Clock.deadline` and check `Clock.reached`: + + - other names: `x -= fixedDt`, `x -= dtSeconds`, `x -= STEP_SECONDS` (any case, with or without `_`), `x -= time.delta`, `x = x - step.seconds`, and a bare `x -= delta`; + - other shapes: `x -= 1 / 60`, `x -= dt * 1`, `elapsedMs += dt * 1000`, `x -= Math.min(dt, 0.1)`, and a `const` that holds a step's length (`const tick = frame.dt; timer -= tick`). + + No longer reported: `x - dt` / `x + dt` whose value is not written back where `x` came from, such as a look-ahead (`const until = horizon + dt`) or a test (`if (accumulator - dt >= 0)`). Written back means a target of the same name (`x = x - dt`, `let cooldown = Math.max(0, state.cooldown - dt)`, `{ left: buff.left - dt }`, `resources.timer.set(timer - dt)`) or an `update` callback's result (`resources.timer.update((left) => left - dt)`); those are still reported. +- 99008ef: `ritmo/use-clock-for-time`, `ritmo/use-random-for-randomness`, `ritmo/use-ops-for-math`, and `ritmo/keep-simulation-headless` find a global however the file reaches it, so simulation code that passed before can now be reported. Newly reported, with the rule's existing message saying what to write instead: + + - through a global object: `globalThis.Math.random()`, `globalThis.crypto.getRandomValues(bytes)`, `globalThis.Date.now()`, `globalThis.performance.now()`, `globalThis.setTimeout(...)`, `self.Math.sin(x)` (fixed to `Ops.sin(x)` like `Math.sin(x)`), `globalThis.localStorage`, `self.innerWidth`; + - through a `const` that holds the global: `const roll = Math; roll.random()`, `const wait = setTimeout; wait(f, 10)`, `const D = Date; new D()`; + - destructured: `const { random } = Math`, `const { randomUUID } = crypto`, `const { now } = Date`, `const { now } = performance`, `const { localStorage } = globalThis`, `const { Math: M } = globalThis` (an alias of Math, like `const M = Math`); + - by `use-clock-for-time`, globals it did not list: `setImmediate(...)`, `process.hrtime()` and `process.hrtime.bigint()`, `performance.timeOrigin`, `performance.mark(...)`, `Temporal.Now`, `Date()` called without `new`, and `getTimezoneOffset()`; + - by `keep-simulation-headless`, sound: `new Audio(...)` and `new AudioContext()`. Publish what happened as an event and play the sound from a render or host system that reads it (`Game.System.readEvent(Hit)`) and requests the game's audio as a service (`Game.System.service(Audio)`); under a netcode session, from `session.events(Hit)`. + + Locals, parameters, and imports with these names, fields of other objects (`config.Date.now()`), and types are still not reported. Host code inside a simulation folder that reads one on purpose takes a disable comment with its reason: `// oxlint-disable-next-line ritmo/use-clock-for-time -- this test is the host`. +- 9baf436: `ritmo/no-infinity-in-simulation-data` reports Infinity and NaN in more of the data a world stores, and no longer reports local scratch. + + Newly reported, with the message saying to use `null` and a nullable number schema: + + - `1 / 0` and `Math.min()` / `Math.max()` of nothing: `{ since: 1 / 0 }`, `{ since: Math.min() }`; + - an element of an array literal: `data.range.set(entity, [0, Infinity])`; + - a call given one, whose result is stored: `{ dash: make(Infinity) }` (`Math.*` and `Number.*` calls are not reported); + - a `const` declared as one: `const LIMIT = Infinity`, then `{ max: LIMIT }`; + - a search seed stored unchecked: `{ distance: best }` after `let best = Infinity`. Store it checked: `{ distance: Number.isFinite(best) ? best : null }`. A comparison with `Infinity` or a `Number.isFinite(best)` anywhere in the file counts as the check. + + No longer reported: a literal search seed in a `let` (`let nearest = { distance: Infinity, id: -1 }`), a fallback in a function whose fields are only compared (`const hit = sweep ?? { time: Infinity }; if (hit.time < limit) ...`), and values a `for...of` tries (`for (const size of [0, Infinity])`). +- 56d6c13: `ritmo/no-kept-query-values` follows entities and `data` through more forms. Newly reported, with the message saying to keep `entity.id` and resolve it with `queries.x.get(id)`, or to keep plain values from `queries.x.rows()`: + + - entities copied or picked into a kept value: `local.list = Array.from(queries.x.each())`, `.map((e) => e)` and `.reduce((best, e) => ...)` results kept in `local` or a module variable, `const [first] = queries.x.each()` then `local.target = first`; + - `Object.assign(local, { target: queries.x.each()[0] })`; + - a context destructured or renamed: `({ queries: q })` then `q.x.data`, `({ queries: { x } })` then `x.data`, `({ local: kept })` then `kept.target = ...`, and `(ctx) => { ctx.local.target = ctx.queries.x.each()[0] }`; + - a system function declared apart and passed to `Game.System(...)` or `Game.Inspector(...)` by name in the same file, keeping an entity in a module variable (`cache.set(1, entity)`). + + Ids stay unreported in each form (`Array.from(queries.x.each(), (e) => e.id)`, `first?.id`). +- 7e416f5: New rule `ritmo/no-kept-query-values`: reports an entity from `queries.x.each()` or a query's `data` kept past its run, in `local` state (directly, in a literal, pushed or set into a collection there, or captured by a closure stored there) or in a variable outside the `Game.System` / `Game.Inspector` callback. Kept data throws in the next run, and a kept entity is not checked against its query again. The report says to keep `entity.id` and resolve it with `queries.x.get(id)`, or to keep plain values from `queries.x.rows()`. Add it to `.oxlintrc.json` for every file: + + ```jsonc + "ritmo/no-kept-query-values": "error" + ``` +- 107196f: `ritmo/handle-runtime-result` finds more dropped and misread results. Newly reported: + + - a result an arrow returns to a caller that drops it: `updates.forEach((u) => runtime.tick(u))`, `requestAnimationFrame(() => runtime.tick(update))`, also `addEventListener`, `requestIdleCallback`, `setTimeout`, `setInterval`, `setImmediate`, and `queueMicrotask`. Check it in a block body, or write `() => void runtime.tick(update)` to ignore it on purpose; + - a result left out of a sequence's value: `runtime.tick(update), draw()`; + - a result tested as a condition, which is always true since a `Result` is an object: `if (!runtime.tick(update))`, `if (runtime.restore(save))`, `runtime.rewind(checkpoint) ? a : b`, the left of `&&` / `||`. Test `.ok` instead: `if (!runtime.tick(update).ok)`; + - a method destructured from a runtime: `const { tick } = runtime`, then `tick(update)`; + - a runtime of any name made in the same file: `made.value.tick(update)` and `game.tick(update)` after `const made = Game.Runtime.make(...)` and `const game = made.value`. +- c3bba11: `ritmo/no-state-spread` reports a `_tag` set over a spread only when the file shows the spread value is a state, and reads more ways of writing the same re-tag. + + A spread value is a state when it is read from a state machine (`machines.x.get()`, `nextMachines.x.getPending()`) or from an entity state's slot the same function moves (`data.contact.get(entity)` next to `data.contact.transition(...)` or `data.contact.update(entity, { state, data })`), directly or through the variable holding it, or when the same function reads its `_tag` (`if (body._tag === "ground")`, `switch (state._tag)`, `const { _tag } = previous`). + + Newly reported, with the message saying to move with `data.x.transition(entity, { from, to, data })` or `nextMachines.x.transition({ from, to, data })`, or to write the new state's fields out: + + - `Object.assign({}, ground, { _tag: "air" })`; + - `{ ...ground, ...{ _tag: "air" } }`; + - `const { _tag, ...rest } = ground`, then `{ _tag: "air", ...rest }`. + + No longer reported: a spread value nothing in the file shows to be a state, such as a plain value tagged for an event (`events.explosion.emit({ ...position, _tag: "Explosion" })`). The compiler already rejects an `update` callback that changes `_tag` and `set` on a state declared with `transitions`. + +### Patch Changes + +- 51efba5: `ritmo/no-state-spread` names `nextMachines.mode.transition({ from, to, data })` as the way to move a state machine, the compare-and-set move state machines now have. +- d2970e8: The `handle-runtime-result` docs say why `runtime.snapshot()` and `runtime.checksum()` return a `Result`: a schema's `encode` may reject a value, and a save cannot hold commands still queued. No change for games. +- 8db5972: The README's `ritmo/no-brand-cast` example compiles: the random resource is `Descriptor.ConstructedResource(Random.codec)("Game/Rng")`, given `Random.seed(1)` in `Game.Runtime.make`, and the reported line is the raw cast `1 as Random.State`. `Random.resource` never existed. + ## 0.22.0 No changes in this release. diff --git a/packages/oxlint-plugin/package.json b/packages/oxlint-plugin/package.json index 966e9c3c..45c1a3e6 100644 --- a/packages/oxlint-plugin/package.json +++ b/packages/oxlint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/oxlint-plugin-ritmo", - "version": "0.22.0", + "version": "0.23.0", "description": "Oxlint rules for Ritmo games: deterministic, headless simulation code and checked runtime results", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/pixi/CHANGELOG.md b/packages/pixi/CHANGELOG.md index 1cb61691..49837a3d 100644 --- a/packages/pixi/CHANGELOG.md +++ b/packages/pixi/CHANGELOG.md @@ -1,5 +1,15 @@ # @typeonce/ritmo-pixi +## 0.23.0 + +### Patch Changes + +- Updated dependencies [3037822] +- Updated dependencies [ca0544d] +- Updated dependencies [d2970e8] +- Updated dependencies [6704ce1] + - @typeonce/ritmo-render@0.23.0 + ## 0.22.0 ### Patch Changes diff --git a/packages/pixi/package.json b/packages/pixi/package.json index f3cbc95b..94a34185 100644 --- a/packages/pixi/package.json +++ b/packages/pixi/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-pixi", - "version": "0.22.0", + "version": "0.23.0", "description": "Pixi container adapter for Ritmo render-node registries", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/render/CHANGELOG.md b/packages/render/CHANGELOG.md index ea20d346..3319ee15 100644 --- a/packages/render/CHANGELOG.md +++ b/packages/render/CHANGELOG.md @@ -1,5 +1,80 @@ # @typeonce/ritmo-render +## 0.23.0 + +### Minor Changes + +- 3037822: **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`: + + ```diff + -const Sprite = Descriptor.Component<{ texture: string; frame: number }>()("Game/Sprite") + +const Sprite = Descriptor.Component<{ texture: string }>()("Game/Sprite") + +const Frame = Descriptor.Component()("Game/Frame") + + RenderSync.system(Game, { + renderable: Sprite, + + select: { frame: Game.Query.read(Frame) }, + + redrawOn: [Frame], + - apply: (node, { renderable }) => node.gotoAndStop(renderable.frame) + + apply: (node, { data }) => node.gotoAndStop(data.frame.get()) + }) + ``` + + 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. + +### Patch Changes + +- ca0544d: `RenderSync` finds a node's parent with `lookup.related`, since core removed `lookup.parent`. No change for games. +- d2970e8: `RenderSync` stays correct across `runtime.restore`, which no longer reports the replaced world's entities as despawned: when its removal reads are lagged it destroys every node whose entity no longer matches, as before, and also the node of every entity added since, so an id the save gave to another entity gets a new node made from that entity's values. No change for games. +- 6704ce1: The `RenderSync.interpolate` example declares its game's root and passes it to `Schema.bind`, which now takes one. +- Updated dependencies [5a16a40] +- Updated dependencies [148ec5f] +- Updated dependencies [4df0496] +- Updated dependencies [4df0496] +- Updated dependencies [0dd0a84] +- Updated dependencies [51a0b7f] +- Updated dependencies [dff932a] +- Updated dependencies [dfe3432] +- Updated dependencies [52d6a6f] +- Updated dependencies [02ca2a4] +- Updated dependencies [7e416f5] +- Updated dependencies [40f60e7] +- Updated dependencies [51efba5] +- Updated dependencies [5f9fe33] +- Updated dependencies [1b83e0c] +- Updated dependencies [ca0544d] +- Updated dependencies [4a32ad1] +- Updated dependencies [917001b] +- Updated dependencies [0f4a5fe] +- Updated dependencies [4df0496] +- Updated dependencies [02b8683] +- Updated dependencies [791517f] +- Updated dependencies [4fc0728] +- Updated dependencies [207e611] +- Updated dependencies [d2970e8] +- Updated dependencies [c99b968] +- Updated dependencies [08c0c91] +- Updated dependencies [78683e7] +- Updated dependencies [04d2a9d] + - @typeonce/ritmo@0.23.0 + ## 0.22.0 ### Patch Changes diff --git a/packages/render/package.json b/packages/render/package.json index 0dc10b3a..a958ba34 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-render", - "version": "0.22.0", + "version": "0.23.0", "description": "Renderer-agnostic entity-to-node mirroring for Ritmo", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md index 5e84867e..bc91a975 100644 --- a/packages/tools/CHANGELOG.md +++ b/packages/tools/CHANGELOG.md @@ -1,5 +1,33 @@ # @typeonce/ritmo-tools +## 0.23.0 + +### Minor Changes + +- 5472516: An fx approval covers everything `fx render` hands the game, a broken draft exits 3 in every `fx` command, and `kill` ends a stuck command. + + - **fx approvals.** Approving an effect (on the page, as before) now also keeps whether it loops, its canvas size, and its anchor, beside the draft, fps, and frame hashes. `fx notes` and `fx render` say when one of them changed (`changedSinceApproved: "its anchor moved from 12,12 to 0,0"`); before, an effect whose anchor moved still read as approved while the game drew it somewhere else. An approval stored before this keeps no anchor or loop: it is not reported as changed for that, but as `unrecorded: true`, with a `next` naming what it did not keep ("Approved, with no record of its anchor or whether it loops: ask the person to look again"), and is not counted among the approved. Ask the person to approve it again on the page. + - **Exit 3 for a broken draft.** `fx render` and `fx look` exit 3, as `fx check` does, for a draft that does not load or whose `draw` throws, names a color or ramp the bank does not have, or runs past the time limit, with `{ error, effect, draft, errors, next }` on stdout. Before, `fx render` exited 2 (a usage error) and `fx look` exited 2 for a draft that does not load and 0 for one whose `draw` throws. + - **Stopping.** SIGTERM and SIGINT end any command at once, even one stuck in synchronous work (a draft that loops as it loads, a large bake); before, the CLI listened for them on its event loop, so such a command outlived `kill` and `timeout` until SIGKILL. A review server still stops on either and exits 130. +- 9952b63: The review pages answer only on this machine, and no longer lose what the agent wrote; every tool's `notes` reports the same things; and bad input is refused with what to change. + + - **Review servers.** A request whose `Host` is not `localhost`, `127.0.0.1`, or `[::1]` on the page's port (or, with `--host`, an address the server printed) gets 403, so a web page open elsewhere cannot reach a game's files by DNS rebinding. `fx review` reads a draft or a stand-in only inside the bank's folder, links followed, and `music review` writes only the song's hum (`.song.reference.wav`, a WAV). + - **No lost writes.** Every bank, review, and song save from a page carries the version of the file the page read; when the agent wrote the file meanwhile (takes from `sound vary`, a rewritten song, a resolved comment), the server answers 409 and writes nothing, and the page lays the person's change over the agent's and saves again. A save that fails stays owed on the sound and fx pages, as on the music page. Pages, servers, and commands write each file whole (to a file beside it, then renamed). + - **Notes.** `sound notes`, `fx notes`, and `music notes` list `gone` as `pixel notes` does: what was said about a sound or effect the bank no longer has, a song's review with no song beside it, or a plain audio file's review whose file is gone, with where to move or remove it; every summary counts them. An approval with no record of what was seen (from an older version, or edited by hand) is `unrecorded: true`, its `next` says to ask the person to look again, and it is no longer counted among the approved in any tool. + - **Limits.** Pictures are read up to 4096 × 4096 pixels in all and 16384 on a side; a header claiming more is refused before decoding instead of running for minutes. `pixel bake` draws at most 1024 frames of at most 1024 art pixels on a side, and a cylinder has at most 256 `sides`. In the CLI, an fx draft's `draw` that runs past 5 seconds on a frame is stopped and is an error: "draw did not return within 5 s on frame F" (`fx check` and `fx look` exit 3). An unexpected throw prints one line, ` failed: `, with no stack, and exits 1. + + **Breaking:** these are refused now where they were taken before. Each says what to change: + + - A key `pixel.config.json`, a pixel recipe, a sound bank or recipe, an fx bank, or a song does not know: `unknown key maxColours: did you mean maxColors?`, exit 2. Rename or remove the key. `pixel.config.json` also lists every problem now, not the first. + - An fx bank's draft `file` or `standIn.file` that is absolute or has a `..` in it. Keep the bank in a folder that holds its drafts and art: + + ```diff + -fx/game.fx.json { "standIn": { "file": "../public/art/hero.png", ... } } + +game.fx.json { "standIn": { "file": "public/art/hero.png", ... } } + ``` + + - A number flag outside its range, which some commands clamped or took: `--count` 1 to 12 (it was clamped), `--sequence` 0 to 10, `--cleanup` 0 to 64, `--shrink-to`, `--width`, and `--height` 1 to 16384, `--scale` 1 to 32, `--colors` 1 to 256, `--columns` 1 to 1024 (`fx look`: 1 to 64), `--spacing` 0 to 64, `--strength` 1 to 400, `--fps` 0.1 to 120, `--tempo` 30 to 300, and `--port` 1 to 65535. The usage error names the flag, the value, and the range (exit 2); pass a value inside it. `pixel sheet --boxes` takes only boxes inside the picture, and `--skip` only frames the sheet has. + ## 0.22.0 ### Minor Changes diff --git a/packages/tools/package.json b/packages/tools/package.json index 3c8a5fca..fb0719e9 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@typeonce/ritmo-tools", - "version": "0.22.0", + "version": "0.23.0", "description": "Asset tools for games made with Ritmo, for agents: fixes AI-made pixel art and animation sheets onto a true pixel grid and a palette, makes normal maps for art a game lights, keeps game sounds as JSON recipes, writes game music as JSON songs built around a hum, and draws pixel art effects as code, each with a review page for a person, pixel art included", "author": "Sandro Maglione", "license": "MIT", diff --git a/packages/tools/src/internal/version.ts b/packages/tools/src/internal/version.ts index d2149e6a..a3734713 100644 --- a/packages/tools/src/internal/version.ts +++ b/packages/tools/src/internal/version.ts @@ -3,4 +3,4 @@ * `package.json` by `scripts/sync-version.ts` (run by `pnpm version-packages`) * and checked by a test. */ -export const toolsVersion = "0.22.0" +export const toolsVersion = "0.23.0"