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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/clock-steps-raw.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@

Literal counts (`Clock.durationOfSteps(step, 3)`) compile as before. A union of literals (`flag ? 3 : 0`) and a literal past `Number.MAX_SAFE_INTEGER` no longer compile either.

**`Clock.duration` and `Clock.durationRaw` now round a half step up, as documented.** A number of seconds whose exact product with the rate is a whole step and a half often landed just below it in floating point and rounded down. These durations are now one step longer, which changes a game's timing where it uses one: `1.025s` at 60 Hz is 62 steps (was 61), `2.05s` at 30 Hz is 62 (was 61), `0.145s` at 100 Hz is 15 (was 14), and `0.29s`, `0.57s`, `1.13s` at 50 Hz are 15, 29, and 57 (were 14, 28, and 56). 57 durations of up to three decimals and 5 seconds moved across 20, 30, 50, 60, 100, 120, 144, and 240 Hz; every other duration is the same step count as before.
**`Clock.duration` and `Clock.durationRaw` now round a half step up, as documented.** A number of seconds whose exact product with the rate is a whole step and a half often landed just below it in floating point and rounded down. These durations are now one step longer, which changes a game's timing where it uses one: `1.025s` at 60 Hz is 62 steps (was 61), `2.05s` at 30 Hz is 62 (was 61), `0.145s` at 100 Hz is 15 (was 14), and `0.29s`, `0.57s`, `1.13s` at 50 Hz are 15, 29, and 57 (were 14, 28, and 56). 57 durations of up to three decimals and 5 seconds moved across 20, 30, 50, 60, 100, 120, 144, and 240 Hz; every other duration is the same step count as before. Replays and fuzz cases recorded with one of these durations diverge: record them again.
10 changes: 10 additions & 0 deletions .changeset/exact-entries.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
| `[Position, { x: 1, y: "2" }]` | `Type 'string' is not assignable to type 'number & FieldOf<"Game/Position", "y">'` |
| `[Outside, { x: 1 }]` (not in the schema) | `... is not assignable to type '"Game/Outside is not a component of this game's schema: spawn and insert take components registered in Schema.fragment({ components: { ... } })"'` |

A tuple built outside the call (returned by a helper, pushed into an array) widens to an array and fails with `this entry widened to an array: ...`. Add `as const` where it is built, or annotate it with `Command.Entry` (from `@typeonce/ritmo/Command`):

```diff
-const wear = () => [[Risk, { fire: 0 }]]
+const wear = () => [[Risk, { fire: 0 }]] as const
-const roles = []
+const roles: Array<Command.Entry<typeof Hp>> = []
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])`.
Expand Down
17 changes: 16 additions & 1 deletion .changeset/render-node-follows-renderable.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"@typeonce/ritmo-render": minor
---

**Breaking: a `RenderSync` node follows its renderable.** A node used to be made once per entity id from the renderable's value at that time and only applied after, so an entity whose renderable a system changed (a relabel, an evolve) kept the node of its old value, and after a rewind an entity spawned under an id already drawn (an enemy where a predicted bullet was) was drawn with the other entity's node for its whole life. Now, when an entity's renderable value is no longer the one its node was made from, `RenderSync.system` destroys the node, calls `create` with the new value, and then `apply`. Writing an equal value keeps the node and runs no extra `apply`: values are compared as plain data (arrays and plain objects by their entries). Nothing changes for a renderable that is set at spawn and never written.
**Breaking, and it still compiles: a renderable a system writes with a new value now destroys and makes its node again on every write.** Count your `create` calls after upgrading: a town whose houses, trees, and fields kept writing their renderables went from 142 nodes made in 5 minutes of play to 59,445, and every effect on those nodes restarted each time.

**A `RenderSync` node follows its renderable.** A node used to be made once per entity id from the renderable's value at that time and only applied after, so an entity whose renderable a system changed (a relabel, an evolve) kept the node of its old value, and after a rewind an entity spawned under an id already drawn (an enemy where a predicted bullet was) was drawn with the other entity's node for its whole life. Now, when an entity's renderable value is no longer the one its node was made from, `RenderSync.system` destroys the node, calls `create` with the new value, and then `apply`. Writing an equal value keeps the node and runs no extra `apply`: values are compared as plain data (arrays and plain objects by their entries). Nothing changes for a renderable that is set at spawn and never written.

What a game sees differently: a renderable written with a new value each tick now makes its node again each tick. Move data that changes while the node lives (an animation `frame`, a `tint`, a facing) out of the renderable into its own component, list it in `redrawOn`, and set it in `apply`:

Expand All @@ -20,4 +22,17 @@ What a game sees differently: a renderable written with a new value each tick no
})
```

Or key the sync on a component that never changes after spawn, and read the one systems write through a required `select` slot:

```diff
RenderSync.system(Game, {
- renderable: House,
+ renderable: Tile,
+ select: { house: Game.Query.read(House) },
+ redrawOn: [House],
- apply: (node, { renderable }) => node.update(renderable)
+ apply: (node, { data }) => node.update(data.house.get())
})
```

A game that listed its renderable in `redrawOn` and wrote an `apply` redoing what `create` did can drop both: a new value makes the node again.
2 changes: 1 addition & 1 deletion .changeset/runtime-robustness.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ Six runtime fixes:
- **Storage no longer grows with every id ever given out.** Ids are never reused, so each 256 ids a component was ever stored on kept a small page for good: about 18 MB after 3 million spawned and despawned entities with 4 components, with 1,000 alive throughout. A page whose entities are all gone now leaves its store. Nothing changes for game code.
- **A debug runtime catches a kept entity whose id is used again.** After a `restore`, or a rewind followed by a spawn, an entity kept from an earlier run (in a closure or `local`) could read and write the new entity that got its id. On a runtime made with `debug`, it now throws `... was used for an entity that was despawned`, like any despawned entity. Runtimes without `debug` are unchanged. The fix is the one the error names: keep `entity.id` and resolve it with `queries.x.get(id)` in each run.
- **`runtime.tick` and `runtime.tryTick` throw when called while either is running.** A `tick` inside a system run by `tryTick` ran, and its commit cleared the outer system's rollback, so when the outer system then failed, its writes stayed. Both now throw `runtime.tick cannot run inside another tick` (or `runtime.tryTick ...`), from a system, a service, or a trace or host listener; so does `debug.beginFrame`, and `FrameCursor.next()` called while a step is running. Run the second schedule after the first returns: `runtime.tick(first); runtime.tick(second)`.
- **`nextMachines.x.reset()` from a transition schedule cancels a request queued before the marker.** When an `onEnter`, `onExit`, or `onTransition` system reset a machine whose request was queued before the current `applyStateTransitions(...)` and not applied yet, the request was applied anyway, while `getPending()` already reported `undefined`. It is now dropped. A `set` from a transition schedule still waits for the next marker, as before.
- **`nextMachines.x.cancelPending()` (named `reset()` before this release) from a transition schedule cancels a request queued before the marker.** When an `onEnter`, `onExit`, or `onTransition` system cancelled a machine's request, and that request was queued before the current `applyStateTransitions(...)` and not applied yet, the request was applied anyway, while `getPending()` already reported `undefined`. It is now dropped. A `set` from a transition schedule still waits for the next marker, as before.
- **`FixedLoop`: `reset()` and `stop()` called from `update` apply to the frame in progress.** A `reset()` from `update` (after a level load) rendered the frame with `alpha: -1` and cost the next frame a step; it now ends the frame's stepping with `alpha` 0. A `stop()` from `update` let the frame's remaining catch-up updates and its `render` run; now nothing more runs.
4 changes: 2 additions & 2 deletions .changeset/saves-and-loads.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A load leaves nothing of the world it replaced, and a save holds the work still

Breaking:

- `runtime.restore(save)` gives every transient resource, and every resource and machine the save does not hold, the value the runtime was made with (`Game.Runtime.make({ resources, machines })`). Each used to keep its live value, so a load kept state of the replaced world: two peers that resync kept their own transient value and desynced again, and a player in `Over` who loaded a save from before that machine existed stayed in `Over`. A transient resource a capture system fills each tick (`InputCapture.system`) is filled again by its next run, so input needs nothing. The fix for any other transient resource, and for a resource the game added after its saves were written: set it again after a successful `restore` the way the game first set it, or give older saves the value with a `Snapshot.migration`.
- `runtime.restore(save)` gives every transient resource, and every resource and machine the save does not hold, the value the runtime was made with (`Game.Runtime.make({ resources, machines })`). Each used to keep its live value, so a load kept state of the replaced world: two peers that resync kept their own transient value and desynced again, and a player in `Over` who loaded a save from before that machine existed stayed in `Over`. A transient resource a capture system fills each tick (`InputCapture.system`) is filled again by its next run, so input needs nothing. The trap is a transient resource a system fills late in a step and another reads early in the next: in the first step after a load it reads the starting value (a town's stock read 0, so the first building after a load failed for lack of planks). `ritmo-devtools lint` notes it as `read-before-write` (info). Fill it in a system at the start of the schedule, or set it again after a successful `restore`; give a resource the game added after its saves were written its value with a `Snapshot.migration`.

- `runtime.restore(save)` publishes nothing about the world it replaced. It used to report every entity of that world as despawned and each of its components as removed, so a system reading `readDespawned()` or `readRemoved(X)` saw entities that were never in the save, and saw them on one peer and not the other (seat 0 sets its world up before the first save). Now every system and inspector that read removed or despawned records, events, transition events, or relation failures before the load finds none from before it, and its next read reports `lagged()`; a reader whose first run comes after the load is not lagged. Restored entities still show as added. A system that mirrors entities (a node or a cache per entity) rebuilds from queries when its read is lagged:

Expand All @@ -28,7 +28,7 @@ Breaking:
+const save: Snapshot.WorldSnapshot = { version: 3, gameVersion: 0, nextEntity: 1, entities: [], relations: {}, resources: {}, machines: {}, nextStates: {}, scopes: {} }
```

A world with nothing queued keeps the checksum it had, so replays recorded before still verify. `Snapshot.SnapshotError` adds `QueuedCommands` and `UnencodableNextState`, and `Snapshot.RestoreError` adds `InvalidNextState`: an exhaustive `switch` on either adds the cases.
Every save now has the field, `{}` when nothing is queued. A world with nothing queued keeps the checksum it had, so replays recorded before still verify, but code that compares saves or fingerprints them itself sees the new field: record those fixtures again. `Snapshot.SnapshotError` adds `QueuedCommands` and `UnencodableNextState`, and `Snapshot.RestoreError` adds `InvalidNextState`: an exhaustive `switch` on either adds the cases.

Fixed: `runtime.checksum()` hashes what a JSON save writes. An object key whose value is `undefined` is skipped and `undefined` in an array hashes as `null`, so a world read back from its JSON save has the checksum of the world that wrote it; a schema with optional fields that accepted `{ a: 1, b: undefined }` made a peer that joined from a save report a desync against the host forever.

Expand Down
7 changes: 7 additions & 0 deletions .changeset/widened-entries-and-set-raw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/ritmo": patch
---

Two messages name the fix. A `[Descriptor, value]` tuple built outside the call (returned by a helper, pushed into an array) widened to an array and failed with `` `${string} is not a component of this game's schema` ``, though the component is in it; it now fails with `this entry widened to an array: write the [Descriptor, value] tuple in the call, add `as const` where it is built, or annotate it Command.Entry<typeof Descriptor>`. 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.
17 changes: 17 additions & 0 deletions packages/core/dtslint/CommandEntries.tst.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Descriptor, Random, Schema } from "@typeonce/ritmo"
import type * as Command from "@typeonce/ritmo/Command"
import * as Codecs from "../test/codecs.ts"
import { describe, expect, it } from "tstyche"

Expand Down Expand Up @@ -111,6 +112,22 @@ describe("an entry is checked against its own component", () => {
Game.Command.spawn([Config, { speed: 1, debug: false }])
})

it("says a tuple built outside the call widened, and accepts it as const or annotated", () => {
const widened = () => [[Hp, 1]]
// @ts-expect-error this entry widened to an array: write the [Descriptor, value] tuple in the call, add `as const` where it is built
Game.Command.spawn(...widened())
const pushed = [Name, "n"]
// @ts-expect-error this entry widened to an array
Game.Command.spawn(pushed)
const constant = () => [[Hp, 1]] as const
expect(Game.Command.spawn(...constant())).type.not.toRaiseError()
const annotated = (hp: number): Command.Entry<typeof Hp> => [Hp, hp]
expect(Game.Command.spawn(annotated(1), [Name, "n"])).type.not.toRaiseError()
const many: Array<Command.Entry<typeof Hp> | Command.Entry<typeof Name>> = []
many.push([Hp, 1], [Name, "n"])
expect(Game.Command.spawn(...many)).type.not.toRaiseError()
})

it("checks inserts the same way", () => {
Game.System("Entries/Insert", {
queries: { positions: Positions },
Expand Down
2 changes: 1 addition & 1 deletion packages/core/dtslint/EntityState.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe("Descriptor.EntityState", () => {
// @ts-expect-error Object literal may only specify known properties, and 'm' does not exist in type
data.mode.set(entity, { _tag: "b", n: 1, m: 1 })
// With one, no set gets around it.
// @ts-expect-error this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state
// @ts-expect-error this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state, or write data from outside the simulation (a test, a level file) with data.x.setRaw(entity, raw)
data.contact.set(entity, { _tag: "ground", platform: null })
// @ts-expect-error this entity state declares `transitions`: move it with data.x.transition(entity, { from, to, data }), which checks the graph and the current state
data.phase.set(entity, { _tag: "ready" })
Expand Down
6 changes: 6 additions & 0 deletions packages/core/skills/ritmo/playbooks/state-and-upgrades.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
- 0.15: the devtools monitor and its "Save replay" button are gone: bind `recorder.save(reason)` to a key in the host.
- 0.23: `restore` leaves nothing of the world it replaced. A transient resource, and a resource or machine the save lacks, takes the value the runtime was made with instead of keeping its own: set again after `restore` what the game kept in one. No despawn or removal of the replaced world is reported: a system that mirrors entities (a node, a cache per entity) rebuilds from queries when its read reports `lagged()`.
- 0.23: `runtime.snapshot()` and `runtime.checksum()` fail with `QueuedCommands` while a command waits for a marker: end the schedule the save follows with `Game.Schedule.applyDeferred()`.
- 0.23: `RenderSync` makes a node again whenever a system writes its renderable with a new value: count `create` calls, and key a sync whose renderable systems write on a component that never changes, reading the written one through a required `select` slot.
- 0.23: a transient resource filled late in a step and read early in the next reads its starting value in the first step after `restore`: fill it at the start of the schedule (`ritmo-devtools lint` notes it as `read-before-write`).
- 0.23: `Clock.duration` rounds a half step up: such a duration is one step longer, and replays recorded with it diverge.
- 0.23: `FlowField` gives a way out of blocked cells, and `Collide2.moveCircle` / `pushOut` no longer pass through walls closing in: AI and balance numbers move. Find the change that moved a baseline before recording it again.
- 0.23: a key pressed with Ctrl or Cmd presses no action unless the bindings hold `"Control"` or `"Meta"`; a gamepad without the standard mapping reads its buttons only (`standard: false`).
- 0.23: a debug runtime throws for an entity kept from an earlier run that no longer matches the system's queries.
- Any version: a new lint rule is off until `.oxlintrc.json` lists it. Compare the config with the `@typeonce/oxlint-plugin-ritmo` README after every upgrade.

**Check:** a test takes a snapshot, round-trips it through `JSON.stringify` and `JSON.parse`, restores it, and asserts each new value; a snapshot taken before the change restores after it; the game's check passes.
Loading
Loading