@typeonce/ritmo-devtools 0.6.0 → 0.7.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.
Files changed (72) hide show
  1. package/README.md +105 -21
  2. package/dist/Determinism.d.ts +70 -0
  3. package/dist/Determinism.d.ts.map +1 -0
  4. package/dist/Determinism.js +137 -0
  5. package/dist/Determinism.js.map +1 -0
  6. package/dist/Format.d.ts +6 -1
  7. package/dist/Format.d.ts.map +1 -1
  8. package/dist/Format.js +24 -0
  9. package/dist/Format.js.map +1 -1
  10. package/dist/Invariant.d.ts +34 -12
  11. package/dist/Invariant.d.ts.map +1 -1
  12. package/dist/Invariant.js +6 -19
  13. package/dist/Invariant.js.map +1 -1
  14. package/dist/Lint.d.ts +60 -0
  15. package/dist/Lint.d.ts.map +1 -0
  16. package/dist/Lint.js +15 -0
  17. package/dist/Lint.js.map +1 -0
  18. package/dist/Monitor.d.ts +18 -4
  19. package/dist/Monitor.d.ts.map +1 -1
  20. package/dist/Monitor.js +31 -20
  21. package/dist/Monitor.js.map +1 -1
  22. package/dist/Replay.d.ts +271 -0
  23. package/dist/Replay.d.ts.map +1 -0
  24. package/dist/Replay.js +335 -0
  25. package/dist/Replay.js.map +1 -0
  26. package/dist/ReplayNodeSink.d.ts +11 -0
  27. package/dist/ReplayNodeSink.d.ts.map +1 -0
  28. package/dist/ReplayNodeSink.js +29 -0
  29. package/dist/ReplayNodeSink.js.map +1 -0
  30. package/dist/ReplayVitePlugin.d.ts +41 -0
  31. package/dist/ReplayVitePlugin.d.ts.map +1 -0
  32. package/dist/ReplayVitePlugin.js +41 -0
  33. package/dist/ReplayVitePlugin.js.map +1 -0
  34. package/dist/ReplayViteSink.d.ts +30 -0
  35. package/dist/ReplayViteSink.d.ts.map +1 -0
  36. package/dist/ReplayViteSink.js +9 -0
  37. package/dist/ReplayViteSink.js.map +1 -0
  38. package/dist/Session.d.ts +66 -14
  39. package/dist/Session.d.ts.map +1 -1
  40. package/dist/Session.js +92 -6
  41. package/dist/Session.js.map +1 -1
  42. package/dist/cli.d.ts +3 -0
  43. package/dist/cli.d.ts.map +1 -0
  44. package/dist/cli.js +57 -0
  45. package/dist/cli.js.map +1 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal/monitor.d.ts +4 -2
  51. package/dist/internal/monitor.d.ts.map +1 -1
  52. package/dist/internal/monitor.js +9 -3
  53. package/dist/internal/monitor.js.map +1 -1
  54. package/dist/internal/version.d.ts +7 -0
  55. package/dist/internal/version.d.ts.map +1 -0
  56. package/dist/internal/version.js +7 -0
  57. package/dist/internal/version.js.map +1 -0
  58. package/package.json +5 -2
  59. package/src/Determinism.ts +190 -0
  60. package/src/Format.ts +25 -0
  61. package/src/Invariant.ts +38 -12
  62. package/src/Lint.ts +72 -0
  63. package/src/Monitor.ts +48 -25
  64. package/src/Replay.ts +583 -0
  65. package/src/ReplayNodeSink.ts +38 -0
  66. package/src/ReplayVitePlugin.ts +74 -0
  67. package/src/ReplayViteSink.ts +33 -0
  68. package/src/Session.ts +153 -23
  69. package/src/cli.ts +55 -0
  70. package/src/index.ts +3 -0
  71. package/src/internal/monitor.ts +12 -5
  72. 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,
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
+ }