@typeonce/ritmo-devtools 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -21
- package/dist/Determinism.d.ts +70 -0
- package/dist/Determinism.d.ts.map +1 -0
- package/dist/Determinism.js +137 -0
- package/dist/Determinism.js.map +1 -0
- package/dist/Format.d.ts +8 -1
- package/dist/Format.d.ts.map +1 -1
- package/dist/Format.js +47 -5
- package/dist/Format.js.map +1 -1
- package/dist/Invariant.d.ts +34 -12
- package/dist/Invariant.d.ts.map +1 -1
- package/dist/Invariant.js +6 -19
- package/dist/Invariant.js.map +1 -1
- package/dist/Lint.d.ts +60 -0
- package/dist/Lint.d.ts.map +1 -0
- package/dist/Lint.js +15 -0
- package/dist/Lint.js.map +1 -0
- package/dist/Monitor.d.ts +18 -4
- package/dist/Monitor.d.ts.map +1 -1
- package/dist/Monitor.js +32 -21
- package/dist/Monitor.js.map +1 -1
- package/dist/Replay.d.ts +271 -0
- package/dist/Replay.d.ts.map +1 -0
- package/dist/Replay.js +335 -0
- package/dist/Replay.js.map +1 -0
- package/dist/ReplayNodeSink.d.ts +11 -0
- package/dist/ReplayNodeSink.d.ts.map +1 -0
- package/dist/ReplayNodeSink.js +29 -0
- package/dist/ReplayNodeSink.js.map +1 -0
- package/dist/ReplayVitePlugin.d.ts +41 -0
- package/dist/ReplayVitePlugin.d.ts.map +1 -0
- package/dist/ReplayVitePlugin.js +41 -0
- package/dist/ReplayVitePlugin.js.map +1 -0
- package/dist/ReplayViteSink.d.ts +30 -0
- package/dist/ReplayViteSink.d.ts.map +1 -0
- package/dist/ReplayViteSink.js +9 -0
- package/dist/ReplayViteSink.js.map +1 -0
- package/dist/Session.d.ts +66 -14
- package/dist/Session.d.ts.map +1 -1
- package/dist/Session.js +93 -7
- package/dist/Session.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +57 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/monitor.d.ts +4 -2
- package/dist/internal/monitor.d.ts.map +1 -1
- package/dist/internal/monitor.js +9 -3
- package/dist/internal/monitor.js.map +1 -1
- package/dist/internal/version.d.ts +7 -0
- package/dist/internal/version.d.ts.map +1 -0
- package/dist/internal/version.js +7 -0
- package/dist/internal/version.js.map +1 -0
- package/package.json +5 -2
- package/src/Determinism.ts +190 -0
- package/src/Format.ts +50 -4
- package/src/Invariant.ts +38 -12
- package/src/Lint.ts +72 -0
- package/src/Monitor.ts +49 -26
- package/src/Replay.ts +583 -0
- package/src/ReplayNodeSink.ts +38 -0
- package/src/ReplayVitePlugin.ts +74 -0
- package/src/ReplayViteSink.ts +33 -0
- package/src/Session.ts +154 -24
- package/src/cli.ts +55 -0
- package/src/index.ts +3 -0
- package/src/internal/monitor.ts +12 -5
- package/src/internal/version.ts +6 -0
package/src/Replay.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replays: the last seconds of a play session, saved as a file an agent can
|
|
3
|
+
* open headless, explain with a session, and turn into an ordinary test.
|
|
4
|
+
*
|
|
5
|
+
* A replay is a debug artifact, not a long-term test: open it, find the bug
|
|
6
|
+
* with `why` / `journal`, `extract` the few ticks that show it into a test,
|
|
7
|
+
* then delete the replay.
|
|
8
|
+
*
|
|
9
|
+
* - `recorder(runtime, options)` keeps a rolling window of one schedule's
|
|
10
|
+
* ticks: periodic world snapshots (so the window has a start), what each
|
|
11
|
+
* of the schedule's input services returned from `snapshot()` each tick
|
|
12
|
+
* (stored as changes from the previous tick), and every `runtime.send`.
|
|
13
|
+
* Time and randomness live in the world, so that is everything the
|
|
14
|
+
* simulation reads. `save(reason)` hands the file to a `Sink`.
|
|
15
|
+
* - `session(make, file)` restores the start and replays the ticks in a
|
|
16
|
+
* normal devtools `Session`; `verify` replays twice and compares the
|
|
17
|
+
* checkpoint hashes to find nondeterminism; `extract` cuts a range of
|
|
18
|
+
* ticks into a small replay with its own start, for a test.
|
|
19
|
+
*
|
|
20
|
+
* Every service the recorded schedule needs must be a `snapshot()` source of
|
|
21
|
+
* plain data (input) or plain data itself (rules, config); anything else is
|
|
22
|
+
* a compile error naming the service, because it could not be replayed.
|
|
23
|
+
* Effects the simulation has on the host go out through the world (events,
|
|
24
|
+
* resources) instead.
|
|
25
|
+
*
|
|
26
|
+
* This module calls no platform API: sinks deliver files (`memorySink`
|
|
27
|
+
* here; `ReplayNodeSink` writes files in Node; `ReplayViteSink` and
|
|
28
|
+
* `ReplayVitePlugin` deliver to a Vite dev server), and wall time comes
|
|
29
|
+
* from the injected `hostClock`.
|
|
30
|
+
*
|
|
31
|
+
* @module Replay
|
|
32
|
+
* @docGroup devtools
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* const recorder = Replay.recorder(runtime, {
|
|
37
|
+
* schedule: simulate, name: "simulate", keep: 30, step: Clock.hz(30),
|
|
38
|
+
* sink: ReplayViteSink.make({ fetch }), hostClock: HostClock.standard,
|
|
39
|
+
* saveOn: { failure: true, invariants: [DeadAreGone] }
|
|
40
|
+
* })
|
|
41
|
+
*
|
|
42
|
+
* // later, in Node:
|
|
43
|
+
* const replay = Replay.session(makeReplaySimulation, Replay.parse(JSON.parse(text)))
|
|
44
|
+
* if (replay.ok) console.log(replay.value.play().text)
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
import type { Clock, HostClock } from "@typeonce/ritmo"
|
|
48
|
+
import type * as Debug from "@typeonce/ritmo/Debug"
|
|
49
|
+
import type { Descriptor } from "@typeonce/ritmo/Descriptor"
|
|
50
|
+
import type * as Requirement from "@typeonce/ritmo/Requirement"
|
|
51
|
+
import * as Result from "@typeonce/ritmo/Result"
|
|
52
|
+
import type { AnyScheduleOrder, ExecutableScheduleDefinition } from "@typeonce/ritmo/Schedule"
|
|
53
|
+
import type { Schema } from "@typeonce/ritmo/Schema"
|
|
54
|
+
import type * as Snapshot from "@typeonce/ritmo/Snapshot"
|
|
55
|
+
|
|
56
|
+
import * as Format from "./Format.ts"
|
|
57
|
+
import type { Invariant } from "./Invariant.ts"
|
|
58
|
+
import { libraryVersion } from "./internal/version.ts"
|
|
59
|
+
import * as Rendered from "./Rendered.ts"
|
|
60
|
+
import * as Session from "./Session.ts"
|
|
61
|
+
|
|
62
|
+
type AnySchedule<S extends Schema.Any, Root> = ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>
|
|
63
|
+
|
|
64
|
+
/** The `@typeonce/ritmo-devtools` version written into replay files. */
|
|
65
|
+
export const version = libraryVersion
|
|
66
|
+
|
|
67
|
+
/** An input service: one value per call, such as a keyboard's actions this step. */
|
|
68
|
+
export interface Source<Data> {
|
|
69
|
+
readonly snapshot: () => Data
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a type is plain data (what JSON keeps): no functions, maps, sets,
|
|
74
|
+
* or class instances with methods. Recursive types are checked once.
|
|
75
|
+
*/
|
|
76
|
+
export type IsPlain<T> = false extends PlainMember<T, never> ? false : true
|
|
77
|
+
|
|
78
|
+
// Checks one member of a union at a time (the conditional distributes), so a
|
|
79
|
+
// recursive type is recognised when it comes back (`Seen`).
|
|
80
|
+
type PlainMember<T, Seen> =
|
|
81
|
+
T extends string | number | boolean | null | undefined ? true
|
|
82
|
+
: [T] extends [Seen] ? true
|
|
83
|
+
: T extends (...args: ReadonlyArray<never>) => unknown ? false
|
|
84
|
+
: T extends ReadonlyArray<infer Element> ? PlainMember<Element, Seen | T>
|
|
85
|
+
: T extends object ? PlainField<T, keyof T, Seen | T>
|
|
86
|
+
: false
|
|
87
|
+
|
|
88
|
+
type PlainField<T, K extends keyof T, Seen> = K extends keyof T ? PlainMember<T[K], Seen> : never
|
|
89
|
+
|
|
90
|
+
type ServiceNeeds<Schedule> = Extract<Requirement.Of<Schedule>, Descriptor<"service", string, any>>
|
|
91
|
+
|
|
92
|
+
type UnrecordableService<Need> =
|
|
93
|
+
Need extends Descriptor<"service", infer Name, infer Value>
|
|
94
|
+
? Value extends Source<infer Data>
|
|
95
|
+
? IsPlain<Data> extends true ? never
|
|
96
|
+
: `${Name}: its snapshot() returns data that is not plain (no functions, maps, or class instances), so it cannot be saved`
|
|
97
|
+
: IsPlain<Value> extends true ? never
|
|
98
|
+
: `${Name} is neither a snapshot() source of plain data nor plain data: give the simulation its input through a source, and its effects on the host through events or resources`
|
|
99
|
+
: never
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rejects a schedule whose services could not be replayed, naming each one.
|
|
103
|
+
* Resolves to `unknown` when every service is a plain-data source or plain data.
|
|
104
|
+
*/
|
|
105
|
+
export type ValidateRecordable<Schedule> =
|
|
106
|
+
[UnrecordableService<ServiceNeeds<Schedule>>] extends [never]
|
|
107
|
+
? unknown
|
|
108
|
+
: { readonly __fixReplayServices__: UnrecordableService<ServiceNeeds<Schedule>> }
|
|
109
|
+
|
|
110
|
+
/** What happened in one recorded tick, beyond what the world already holds. */
|
|
111
|
+
export interface TickRecord {
|
|
112
|
+
/** The recorder's tick number, counting the recorded schedule's ticks from 1. */
|
|
113
|
+
readonly tick: number
|
|
114
|
+
/** Per source service: what `snapshot()` returned this tick, in call order. Absent when it repeats the previous tick. */
|
|
115
|
+
readonly services?: Readonly<Record<string, ReadonlyArray<unknown>>>
|
|
116
|
+
/** Events the host sent since the previous recorded tick, in order. */
|
|
117
|
+
readonly sent?: ReadonlyArray<{ readonly event: string; readonly value: unknown }>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A replay file (JSON). */
|
|
121
|
+
export interface ReplayFile {
|
|
122
|
+
readonly format: "ritmo-replay"
|
|
123
|
+
readonly version: 1
|
|
124
|
+
/** The `@typeonce/ritmo-devtools` version that recorded it. */
|
|
125
|
+
readonly library: string
|
|
126
|
+
/** Hash of the schema (components, resources, events, relations, machines) from `describe()`. */
|
|
127
|
+
readonly schema: string
|
|
128
|
+
/** The recorded schedule's name. */
|
|
129
|
+
readonly schedule: string
|
|
130
|
+
/** Why it was saved, e.g. "invariant DeadAreGone broke at tick 1412". */
|
|
131
|
+
readonly reason: string
|
|
132
|
+
/** `hostClock.now()` when it was saved. */
|
|
133
|
+
readonly savedAt: number
|
|
134
|
+
/** Services that are plain data, as they were (rules, config). */
|
|
135
|
+
readonly constants: Readonly<Record<string, unknown>>
|
|
136
|
+
/** The world at the end of tick `start.tick`; replay starts with the tick after it. */
|
|
137
|
+
readonly start: {
|
|
138
|
+
readonly tick: number
|
|
139
|
+
readonly snapshot: Snapshot.WorldSnapshot
|
|
140
|
+
/** Commands queued and not yet applied at that point: a snapshot does not hold them, so a replay may diverge. */
|
|
141
|
+
readonly pendingCommands: number
|
|
142
|
+
}
|
|
143
|
+
readonly ticks: ReadonlyArray<TickRecord>
|
|
144
|
+
/** Hashes of the world at the end of some ticks, to compare a replay against. */
|
|
145
|
+
readonly checkpoints: ReadonlyArray<{ readonly tick: number; readonly hash: string }>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Where `save` hands a replay. */
|
|
149
|
+
export interface Sink {
|
|
150
|
+
readonly save: (file: ReplayFile) => void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A sink that keeps replays in memory (for tests, or for a host that delivers them itself). */
|
|
154
|
+
export const memorySink = (): Sink & { readonly files: ReadonlyArray<ReplayFile> } => {
|
|
155
|
+
const files: Array<ReplayFile> = []
|
|
156
|
+
return { files, save: (file) => { files.push(file) } }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What a recorder needs from a runtime: made with `debug: { hostClock }`, with snapshots available. */
|
|
160
|
+
export interface Recordable<S extends Schema.Any, Root> {
|
|
161
|
+
readonly debug: Debug.Handle<S, Root>
|
|
162
|
+
readonly snapshot: () => Snapshot.WorldSnapshot
|
|
163
|
+
readonly services: Readonly<Record<string, unknown>>
|
|
164
|
+
readonly inspect: (inspector: never) => unknown
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface RecorderOptions<S extends Schema.Any, Root, Recorded extends AnySchedule<S, Root>> {
|
|
168
|
+
/** The schedule to record: the one ticked once per simulation step. */
|
|
169
|
+
readonly schedule: Recorded
|
|
170
|
+
/** Its name in the file and in the replay's session. */
|
|
171
|
+
readonly name: string
|
|
172
|
+
/** Seconds of play to keep. */
|
|
173
|
+
readonly keep: number
|
|
174
|
+
/** The schedule's fixed step (`Clock.hz(...)`), the same the game's clock runs at. */
|
|
175
|
+
readonly step: Clock.Step
|
|
176
|
+
/** Seconds between world snapshots. Defaults to 5. */
|
|
177
|
+
readonly every?: number
|
|
178
|
+
readonly sink: Sink
|
|
179
|
+
/**
|
|
180
|
+
* Wall time, for `savedAt` (milliseconds since the epoch): sinks name
|
|
181
|
+
* files from it, so use `HostClock.standard`, not a page-relative clock
|
|
182
|
+
* like `BrowserHostClock` (`HostClock.test()` in tests).
|
|
183
|
+
*/
|
|
184
|
+
readonly hostClock: HostClock.HostClock
|
|
185
|
+
/** Saves by itself: when a tick fails or throws, and when an invariant breaks (once until it holds again). */
|
|
186
|
+
readonly saveOn?: {
|
|
187
|
+
readonly failure?: boolean
|
|
188
|
+
readonly invariants?: ReadonlyArray<Invariant<Invariant.CheckInspector>>
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface Recorder {
|
|
193
|
+
/** Hands the current window to the sink, with `reason`, and returns it. */
|
|
194
|
+
readonly save: (reason: string) => ReplayFile
|
|
195
|
+
/** Ticks of the recorded schedule so far. */
|
|
196
|
+
readonly ticks: () => number
|
|
197
|
+
/** Stops recording and restores the services' own `snapshot`. */
|
|
198
|
+
readonly stop: () => void
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** FNV-1a over a string, as 8 hex digits: a cheap fingerprint for checkpoints and schemas. */
|
|
202
|
+
const fingerprint = (text: string): string => {
|
|
203
|
+
let hash = 0x811c9dc5
|
|
204
|
+
for (let index = 0; index < text.length; index++) {
|
|
205
|
+
hash ^= text.charCodeAt(index)
|
|
206
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
207
|
+
}
|
|
208
|
+
return hash.toString(16).padStart(8, "0")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The schema's fingerprint: what must match for a replay to load. */
|
|
212
|
+
export const schemaHash = <S extends Schema.Any, Root>(debug: Debug.Handle<S, Root>): string => {
|
|
213
|
+
const description = debug.describe()
|
|
214
|
+
return fingerprint(JSON.stringify({
|
|
215
|
+
components: description.components.map(({ name, storage }) => ({ name, storage })),
|
|
216
|
+
resources: description.resources.map(({ name, storage }) => ({ name, storage })),
|
|
217
|
+
events: description.events,
|
|
218
|
+
relations: description.relations,
|
|
219
|
+
machines: description.machines.map(({ name, states }) => ({ name, states }))
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const worldHash = (snapshot: Snapshot.WorldSnapshot): string => fingerprint(JSON.stringify(snapshot))
|
|
224
|
+
|
|
225
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
|
|
226
|
+
|
|
227
|
+
const isSource = (value: unknown): value is Source<unknown> => isRecord(value) && typeof value["snapshot"] === "function"
|
|
228
|
+
|
|
229
|
+
/** The names of the services a schedule needs. */
|
|
230
|
+
const serviceNames = (schedule: { readonly requirements: ReadonlyArray<unknown> }): Array<string> =>
|
|
231
|
+
schedule.requirements.flatMap((requirement) =>
|
|
232
|
+
isRecord(requirement) && requirement["kind"] === "service" && typeof requirement["name"] === "string" ? [requirement["name"]] : [])
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Starts recording a runtime made with `debug: { hostClock }`. The schedule's source
|
|
236
|
+
* services have their `snapshot` wrapped to record what the systems got; the
|
|
237
|
+
* services must not be frozen.
|
|
238
|
+
*/
|
|
239
|
+
export const recorder = <S extends Schema.Any, Root, const Recorded extends AnySchedule<S, Root>>(
|
|
240
|
+
runtime: Recordable<S, Root>,
|
|
241
|
+
options: RecorderOptions<S, Root, Recorded> & ValidateRecordable<Recorded>
|
|
242
|
+
): Recorder => {
|
|
243
|
+
const keepTicks = Math.max(1, Math.ceil(options.keep / options.step.dt))
|
|
244
|
+
const everyTicks = Math.max(1, Math.round((options.every ?? 5) / options.step.dt))
|
|
245
|
+
const constants: Record<string, unknown> = {}
|
|
246
|
+
const restores: Array<() => void> = []
|
|
247
|
+
let current: Map<string, Array<unknown>> | undefined
|
|
248
|
+
for (const name of serviceNames(options.schedule)) {
|
|
249
|
+
const service = runtime.services[name]
|
|
250
|
+
if (!isSource(service)) {
|
|
251
|
+
constants[name] = service
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
const own = service.snapshot
|
|
255
|
+
Reflect.set(service, "snapshot", () => {
|
|
256
|
+
const value = own.call(service)
|
|
257
|
+
if (current !== undefined) {
|
|
258
|
+
const calls = current.get(name)
|
|
259
|
+
if (calls === undefined) current.set(name, [value])
|
|
260
|
+
else calls.push(value)
|
|
261
|
+
}
|
|
262
|
+
return value
|
|
263
|
+
})
|
|
264
|
+
restores.push(() => Reflect.set(service, "snapshot", own))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let tick = 0
|
|
268
|
+
let sent: Array<{ readonly event: string; readonly value: unknown }> = []
|
|
269
|
+
let ticks: Array<TickRecord> = []
|
|
270
|
+
/** Snapshot points still needed: the newest one at or before the window start, and every later one. */
|
|
271
|
+
let points: Array<{ readonly tick: number; readonly snapshot: Snapshot.WorldSnapshot; readonly pendingCommands: number }> = []
|
|
272
|
+
let checkpoints: Array<{ readonly tick: number; readonly hash: string }> = []
|
|
273
|
+
const previous = new Map<string, string>()
|
|
274
|
+
const broken = new Set<string>()
|
|
275
|
+
|
|
276
|
+
const takePoint = () => {
|
|
277
|
+
const snapshot = runtime.snapshot()
|
|
278
|
+
points.push({ tick, snapshot, pendingCommands: runtime.debug.dump({ limit: 0 }).pendingCommands.length })
|
|
279
|
+
checkpoints.push({ tick, hash: worldHash(snapshot) })
|
|
280
|
+
const windowStart = tick - keepTicks
|
|
281
|
+
let firstNeeded = -1
|
|
282
|
+
for (let index = 0; index < points.length; index++) if (points[index]!.tick <= windowStart) firstNeeded = index
|
|
283
|
+
if (firstNeeded > 0) {
|
|
284
|
+
points = points.slice(firstNeeded)
|
|
285
|
+
ticks = ticks.filter((record) => record.tick > points[0]!.tick)
|
|
286
|
+
checkpoints = checkpoints.filter((checkpoint) => checkpoint.tick >= points[0]!.tick)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
takePoint()
|
|
290
|
+
|
|
291
|
+
const save = (reason: string): ReplayFile => {
|
|
292
|
+
const start = points[0]!
|
|
293
|
+
const file: ReplayFile = {
|
|
294
|
+
format: "ritmo-replay",
|
|
295
|
+
version: 1,
|
|
296
|
+
library: libraryVersion,
|
|
297
|
+
schema: schemaHash(runtime.debug),
|
|
298
|
+
schedule: options.name,
|
|
299
|
+
reason,
|
|
300
|
+
savedAt: options.hostClock.now(),
|
|
301
|
+
constants,
|
|
302
|
+
start,
|
|
303
|
+
ticks: ticks.filter((record) => record.tick > start.tick),
|
|
304
|
+
checkpoints: checkpoints.filter((checkpoint) => checkpoint.tick >= start.tick)
|
|
305
|
+
}
|
|
306
|
+
options.sink.save(file)
|
|
307
|
+
return file
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const check = runtime.inspect as (inspector: Invariant.CheckInspector) => string | undefined
|
|
311
|
+
const stopObserving = runtime.debug.observeHost((event) => {
|
|
312
|
+
if (event.type === "send") {
|
|
313
|
+
sent.push({ event: event.event, value: event.value })
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (event.type === "failure.ignored") {
|
|
317
|
+
// An unread failure is a failure nobody saw: save it like one.
|
|
318
|
+
if (options.saveOn?.failure === true) save(Format.ignoredFailure(event))
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (!event.schedules.includes(options.schedule)) return
|
|
322
|
+
if (event.type === "tick.start") {
|
|
323
|
+
tick += 1
|
|
324
|
+
current = new Map()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
const calls = current ?? new Map<string, Array<unknown>>()
|
|
328
|
+
current = undefined
|
|
329
|
+
const changed: Record<string, ReadonlyArray<unknown>> = {}
|
|
330
|
+
for (const [name, values] of calls) {
|
|
331
|
+
const text = JSON.stringify(values)
|
|
332
|
+
if (previous.get(name) !== text) changed[name] = values
|
|
333
|
+
previous.set(name, text)
|
|
334
|
+
}
|
|
335
|
+
ticks.push({
|
|
336
|
+
tick,
|
|
337
|
+
...(Object.keys(changed).length > 0 ? { services: changed } : {}),
|
|
338
|
+
...(sent.length > 0 ? { sent } : {})
|
|
339
|
+
})
|
|
340
|
+
sent = []
|
|
341
|
+
if (tick % everyTicks === 0) takePoint()
|
|
342
|
+
if (event.outcome !== "ok" && options.saveOn?.failure === true) {
|
|
343
|
+
save(`tick ${tick} ${event.outcome === "defect" ? "threw" : "failed"}: ${describeError(event.error)}`)
|
|
344
|
+
}
|
|
345
|
+
for (const invariant of options.saveOn?.invariants ?? []) {
|
|
346
|
+
const message = check(invariant.inspector)
|
|
347
|
+
if (message === undefined) {
|
|
348
|
+
broken.delete(invariant.name)
|
|
349
|
+
} else if (!broken.has(invariant.name)) {
|
|
350
|
+
broken.add(invariant.name)
|
|
351
|
+
save(`invariant ${invariant.name} broke at tick ${tick}: ${message}`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
save,
|
|
358
|
+
ticks: () => tick,
|
|
359
|
+
stop: () => {
|
|
360
|
+
stopObserving()
|
|
361
|
+
for (const restore of restores) restore()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const describeError = (error: unknown): string => {
|
|
367
|
+
if (error instanceof Error) return error.message
|
|
368
|
+
if (isRecord(error) && typeof error["system"] === "string") return `${error["system"]} returned ${JSON.stringify(error["error"])}`
|
|
369
|
+
return JSON.stringify(error) ?? String(error)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Why a replay could not be opened. */
|
|
373
|
+
export type ReplayError =
|
|
374
|
+
| { readonly _tag: "InvalidReplay"; readonly message: string }
|
|
375
|
+
| { readonly _tag: "SchemaMismatch"; readonly message: "replay recorded with an older schema"; readonly recorded: string; readonly current: string }
|
|
376
|
+
| { readonly _tag: "MissingSchedule"; readonly schedule: string }
|
|
377
|
+
| { readonly _tag: "RestoreFailed"; readonly error: Snapshot.RestoreError }
|
|
378
|
+
|
|
379
|
+
/** Checks the shape of untrusted replay data (`JSON.parse` output). */
|
|
380
|
+
export const parse = (data: unknown): Result.Result<ReplayFile, ReplayError> => {
|
|
381
|
+
const invalid = (message: string) => Result.failure<ReplayError>({ _tag: "InvalidReplay", message })
|
|
382
|
+
if (!isRecord(data) || data["format"] !== "ritmo-replay") return invalid("not a ritmo replay (format)")
|
|
383
|
+
if (data["version"] !== 1) return invalid(`unsupported replay version ${JSON.stringify(data["version"])}`)
|
|
384
|
+
for (const key of ["library", "schema", "schedule", "reason"] as const) {
|
|
385
|
+
if (typeof data[key] !== "string") return invalid(`${key} must be a string`)
|
|
386
|
+
}
|
|
387
|
+
const start = data["start"]
|
|
388
|
+
if (!isRecord(start) || typeof start["tick"] !== "number" || !isRecord(start["snapshot"])) return invalid("start must hold a tick and a snapshot")
|
|
389
|
+
if (!Array.isArray(data["ticks"]) || !data["ticks"].every((record) => isRecord(record) && typeof record["tick"] === "number")) return invalid("ticks must be tick records")
|
|
390
|
+
if (!Array.isArray(data["checkpoints"])) return invalid("checkpoints must be a list")
|
|
391
|
+
if (!isRecord(data["constants"])) return invalid("constants must be an object")
|
|
392
|
+
// The snapshot itself is validated by `restore`, and service values by the systems that read them.
|
|
393
|
+
return Result.success(data as unknown as ReplayFile)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What a replay hands the game's `make` function: the recorded input, as
|
|
398
|
+
* sources to provide in place of the live ones, and the recorded constants.
|
|
399
|
+
*/
|
|
400
|
+
export interface Playback {
|
|
401
|
+
/** A source returning, call by call, what the recorded one returned at the current tick. */
|
|
402
|
+
readonly source: <const Name extends string, Data>(descriptor: Descriptor<"service", Name, Source<Data>>) => Source<Data>
|
|
403
|
+
/** A constant service's recorded value (`undefined` if it was not recorded). */
|
|
404
|
+
readonly constant: <const Name extends string, Value>(descriptor: Descriptor<"service", Name, Value>) => unknown
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* What the game's `make` function returns: a fresh runtime
|
|
409
|
+
* (`debug: { hostClock }`) built on the playback, the recorded schedule by
|
|
410
|
+
* name, and the clock the replay session times runs with
|
|
411
|
+
* (`HostClock.test()` for identical text output).
|
|
412
|
+
*/
|
|
413
|
+
export interface Target<S extends Schema.Any, Root> {
|
|
414
|
+
readonly hostClock: HostClock.HostClock
|
|
415
|
+
readonly runtime: Session.Target<S, Root> & {
|
|
416
|
+
readonly snapshot: () => Snapshot.WorldSnapshot
|
|
417
|
+
readonly restore: (data: unknown) => Result.Result<void, Snapshot.RestoreError>
|
|
418
|
+
readonly send: (descriptor: never, value: never) => void
|
|
419
|
+
}
|
|
420
|
+
readonly schedules: Readonly<Record<string, AnySchedule<S, Root>>>
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export interface PlayResult {
|
|
424
|
+
/** The session's run of the replayed ticks. */
|
|
425
|
+
readonly run: Session.RunResult
|
|
426
|
+
/** Recorded checkpoints whose hash differs in this replay: the simulation is not deterministic, or the code changed. */
|
|
427
|
+
readonly diverged: ReadonlyArray<{ readonly tick: number; readonly recorded: string; readonly replayed: string }>
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface ReplaySession<S extends Schema.Any, Names extends string> {
|
|
431
|
+
/** The devtools session the ticks run in: `why`, `journal`, `report` work as in any run. */
|
|
432
|
+
readonly session: Session.Session<S, Names>
|
|
433
|
+
readonly file: ReplayFile
|
|
434
|
+
/** Replays the recorded ticks (up to `to`, the recorder's tick number) and compares checkpoints. */
|
|
435
|
+
readonly play: (options?: { readonly to?: number }) => Rendered.Rendered<PlayResult>
|
|
436
|
+
/** Hashes of the world at the end of each replayed tick so far. */
|
|
437
|
+
readonly hashes: () => ReadonlyMap<number, string>
|
|
438
|
+
/** The world as plain data now. */
|
|
439
|
+
readonly snapshot: () => Snapshot.WorldSnapshot
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Opens a replay: builds the game with `make(playback)`, checks the schema,
|
|
444
|
+
* restores the start, and returns a session ready to `play`.
|
|
445
|
+
*/
|
|
446
|
+
export const session = <S extends Schema.Any, Root>(
|
|
447
|
+
make: (playback: Playback) => Target<S, Root>,
|
|
448
|
+
file: ReplayFile
|
|
449
|
+
): Result.Result<ReplaySession<S, string>, ReplayError> => {
|
|
450
|
+
/** The last tick replayed (the start's tick before any). */
|
|
451
|
+
let ran = file.start.tick
|
|
452
|
+
let calls = new Map<string, number>()
|
|
453
|
+
const latest = new Map<string, ReadonlyArray<unknown>>()
|
|
454
|
+
const byTick = new Map(file.ticks.map((record) => [record.tick, record] as const))
|
|
455
|
+
const playback: Playback = {
|
|
456
|
+
source: (descriptor) => ({
|
|
457
|
+
snapshot: () => {
|
|
458
|
+
const values = latest.get(descriptor.name) ?? []
|
|
459
|
+
const index = calls.get(descriptor.name) ?? 0
|
|
460
|
+
calls.set(descriptor.name, index + 1)
|
|
461
|
+
// Each call this tick returns what the recorded call returned (the last one again past the end).
|
|
462
|
+
return values[Math.min(index, values.length - 1)] as never
|
|
463
|
+
}
|
|
464
|
+
}),
|
|
465
|
+
constant: (descriptor) => file.constants[descriptor.name]
|
|
466
|
+
}
|
|
467
|
+
const target = make(playback)
|
|
468
|
+
const schedule = target.schedules[file.schedule]
|
|
469
|
+
if (schedule === undefined) return Result.failure({ _tag: "MissingSchedule", schedule: file.schedule })
|
|
470
|
+
const current = schemaHash(target.runtime.debug)
|
|
471
|
+
if (current !== file.schema) {
|
|
472
|
+
return Result.failure({ _tag: "SchemaMismatch", message: "replay recorded with an older schema", recorded: file.schema, current })
|
|
473
|
+
}
|
|
474
|
+
const restored = target.runtime.restore(file.start.snapshot)
|
|
475
|
+
if (!restored.ok) return Result.failure({ _tag: "RestoreFailed", error: restored.error })
|
|
476
|
+
const replay = Session.make(target.runtime, { schedules: { [file.schedule]: schedule }, hostClock: target.hostClock })
|
|
477
|
+
const send = target.runtime.send as (descriptor: { readonly kind: "event"; readonly name: string; readonly key: symbol }, value: unknown) => void
|
|
478
|
+
const events = new Map(target.runtime.debug.describe().events.map(({ name }) => [name, { kind: "event" as const, name, key: Symbol.for(`ritmo/event/${name}`) }]))
|
|
479
|
+
const hashes = new Map<number, string>([[ran, worldHash(target.runtime.snapshot())]])
|
|
480
|
+
const recorded = new Map(file.checkpoints.map(({ tick, hash }) => [tick, hash] as const))
|
|
481
|
+
const last = file.ticks.at(-1)?.tick ?? file.start.tick
|
|
482
|
+
|
|
483
|
+
const play = (options: { readonly to?: number } = {}): Rendered.Rendered<PlayResult> => {
|
|
484
|
+
const to = Math.min(options.to ?? last, last)
|
|
485
|
+
const frames = Math.max(0, to - ran)
|
|
486
|
+
const diverged: Array<{ readonly tick: number; readonly recorded: string; readonly replayed: string }> = []
|
|
487
|
+
const run = replay.run(file.schedule, {
|
|
488
|
+
frames,
|
|
489
|
+
// `until` runs after each frame: the tick is checked, and the next one's input set up before it runs.
|
|
490
|
+
until: () => {
|
|
491
|
+
ran += 1
|
|
492
|
+
const hash = worldHash(target.runtime.snapshot())
|
|
493
|
+
hashes.set(ran, hash)
|
|
494
|
+
const expected = recorded.get(ran)
|
|
495
|
+
if (expected !== undefined && expected !== hash) diverged.push({ tick: ran, recorded: expected, replayed: hash })
|
|
496
|
+
prepare(ran + 1)
|
|
497
|
+
return false
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
return Rendered.make({ run: run.data, diverged }, diverged.length === 0
|
|
501
|
+
? `${run.text}\ncheckpoints match`
|
|
502
|
+
: `${run.text}\nDIVERGED at ${diverged.map((entry) => `tick ${entry.tick}`).join(", ")}: the simulation is not deterministic, or the code changed since recording`)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Sets up tick `next`: its sends, and its sources' values. */
|
|
506
|
+
const prepare = (next: number) => {
|
|
507
|
+
const record = byTick.get(next)
|
|
508
|
+
for (const [name, values] of Object.entries(record?.services ?? {})) latest.set(name, values)
|
|
509
|
+
calls = new Map()
|
|
510
|
+
for (const { event, value } of record?.sent ?? []) {
|
|
511
|
+
const descriptor = events.get(event)
|
|
512
|
+
if (descriptor !== undefined) send(descriptor, value)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
prepare(ran + 1)
|
|
516
|
+
|
|
517
|
+
return Result.success({
|
|
518
|
+
session: replay,
|
|
519
|
+
file,
|
|
520
|
+
play,
|
|
521
|
+
hashes: () => hashes,
|
|
522
|
+
snapshot: () => target.runtime.snapshot()
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Replays twice and compares the world after every tick: different hashes
|
|
528
|
+
* mean the simulation reads something the replay does not hold (wall time,
|
|
529
|
+
* `Math.random`, host state).
|
|
530
|
+
*/
|
|
531
|
+
export const verify = <S extends Schema.Any, Root>(
|
|
532
|
+
make: (playback: Playback) => Target<S, Root>,
|
|
533
|
+
file: ReplayFile
|
|
534
|
+
): Result.Result<Rendered.Rendered<{ readonly deterministic: boolean; readonly firstDifference: number | undefined; readonly diverged: PlayResult["diverged"] }>, ReplayError> => {
|
|
535
|
+
const first = session(make, file)
|
|
536
|
+
if (!first.ok) return first
|
|
537
|
+
const second = session(make, file)
|
|
538
|
+
if (!second.ok) return second
|
|
539
|
+
const played = first.value.play()
|
|
540
|
+
second.value.play()
|
|
541
|
+
const ticks = [...first.value.hashes().keys()].sort((left, right) => left - right)
|
|
542
|
+
const firstDifference = ticks.find((tick) => first.value.hashes().get(tick) !== second.value.hashes().get(tick))
|
|
543
|
+
const deterministic = firstDifference === undefined
|
|
544
|
+
return Result.success(Rendered.make(
|
|
545
|
+
{ deterministic, firstDifference, diverged: played.data.diverged },
|
|
546
|
+
deterministic
|
|
547
|
+
? `deterministic over ${ticks.length - 1} ticks${played.data.diverged.length === 0 ? ", and matches the recording" : `, but differs from the recording at tick ${played.data.diverged[0]!.tick}`}`
|
|
548
|
+
: `NOT deterministic: two replays differ from tick ${firstDifference}`
|
|
549
|
+
))
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Cuts ticks `from`..`to` (recorder tick numbers) into a smaller replay that
|
|
554
|
+
* starts at the end of tick `from - 1`: few enough ticks to keep next to a
|
|
555
|
+
* test and play with `session`.
|
|
556
|
+
*/
|
|
557
|
+
export const extract = <S extends Schema.Any, Root>(
|
|
558
|
+
make: (playback: Playback) => Target<S, Root>,
|
|
559
|
+
file: ReplayFile,
|
|
560
|
+
range: { readonly from: number; readonly to: number }
|
|
561
|
+
): Result.Result<ReplayFile, ReplayError> => {
|
|
562
|
+
const opened = session(make, file)
|
|
563
|
+
if (!opened.ok) return opened
|
|
564
|
+
opened.value.play({ to: range.from - 1 })
|
|
565
|
+
const start = opened.value.snapshot()
|
|
566
|
+
// Each kept tick carries its sources' values in full, so the extract does not depend on earlier ticks.
|
|
567
|
+
const latest = new Map<string, ReadonlyArray<unknown>>()
|
|
568
|
+
const ticks: Array<TickRecord> = []
|
|
569
|
+
for (const record of file.ticks) {
|
|
570
|
+
for (const [name, values] of Object.entries(record.services ?? {})) latest.set(name, values)
|
|
571
|
+
if (record.tick < range.from || record.tick > range.to) continue
|
|
572
|
+
ticks.push({ tick: record.tick, services: Object.fromEntries(latest), ...(record.sent === undefined ? {} : { sent: record.sent }) })
|
|
573
|
+
}
|
|
574
|
+
opened.value.play({ to: range.to })
|
|
575
|
+
const checkpoints = [...opened.value.hashes()].filter(([tick]) => tick >= range.from - 1 && tick <= range.to).map(([tick, hash]) => ({ tick, hash }))
|
|
576
|
+
return Result.success({
|
|
577
|
+
...file,
|
|
578
|
+
reason: `${file.reason} (ticks ${range.from}-${range.to})`,
|
|
579
|
+
start: { tick: range.from - 1, snapshot: start, pendingCommands: 0 },
|
|
580
|
+
ticks,
|
|
581
|
+
checkpoints
|
|
582
|
+
})
|
|
583
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `Replay.Sink` for Node: each saved replay becomes a JSON file in a
|
|
3
|
+
* directory. For headless runs (bots, scripts) that record replays.
|
|
4
|
+
*
|
|
5
|
+
* @module ReplayNodeSink
|
|
6
|
+
* @docGroup devtools
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const recorder = Replay.recorder(runtime, { ..., sink: ReplayNodeSink.make({ directory: ".replays" }) })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
|
|
16
|
+
import type * as Replay from "./Replay.ts"
|
|
17
|
+
|
|
18
|
+
/** Where the last replay was written, and every path written so far. */
|
|
19
|
+
export interface NodeSink extends Replay.Sink {
|
|
20
|
+
readonly written: ReadonlyArray<string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The file name of a replay: its save time, then its tick count. */
|
|
24
|
+
export const fileName = (file: Replay.ReplayFile): string =>
|
|
25
|
+
`${new Date(file.savedAt).toISOString().replaceAll(":", "-")}-tick-${file.ticks.at(-1)?.tick ?? file.start.tick}.json`
|
|
26
|
+
|
|
27
|
+
export const make = (options: { readonly directory: string }): NodeSink => {
|
|
28
|
+
const written: Array<string> = []
|
|
29
|
+
return {
|
|
30
|
+
written,
|
|
31
|
+
save: (file) => {
|
|
32
|
+
mkdirSync(options.directory, { recursive: true })
|
|
33
|
+
const path = join(options.directory, fileName(file))
|
|
34
|
+
writeFileSync(path, `${JSON.stringify(file)}\n`)
|
|
35
|
+
written.push(path)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|