@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.
- 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 +6 -1
- package/dist/Format.d.ts.map +1 -1
- package/dist/Format.js +24 -0
- 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 +31 -20
- 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 +92 -6
- 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 +25 -0
- package/src/Invariant.ts +38 -12
- package/src/Lint.ts +72 -0
- package/src/Monitor.ts +48 -25
- 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 +153 -23
- 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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Vite plugin (Node side) that receives the replays `ReplayViteSink`
|
|
3
|
+
* posts from the page and writes them into the game's repository as
|
|
4
|
+
* `.replays/<time>.json`, logging the path, the tick count, and the reason.
|
|
5
|
+
* Development only: it does nothing in a build.
|
|
6
|
+
*
|
|
7
|
+
* Add `.replays/` to the game's `.gitignore`.
|
|
8
|
+
*
|
|
9
|
+
* @module ReplayVitePlugin
|
|
10
|
+
* @docGroup devtools
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* // vite.config.ts
|
|
15
|
+
* import * as ReplayVitePlugin from "@typeonce/ritmo-devtools/ReplayVitePlugin"
|
|
16
|
+
* export default defineConfig({ plugins: [ReplayVitePlugin.plugin()] })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
20
|
+
|
|
21
|
+
import * as Replay from "./Replay.ts"
|
|
22
|
+
import * as ReplayNodeSink from "./ReplayNodeSink.ts"
|
|
23
|
+
import { endpoint } from "./ReplayViteSink.ts"
|
|
24
|
+
|
|
25
|
+
/** The part of Vite's dev server the plugin uses. */
|
|
26
|
+
export interface DevServer {
|
|
27
|
+
readonly middlewares: {
|
|
28
|
+
readonly use: (path: string, handler: (request: IncomingMessage, response: ServerResponse, next: () => void) => void) => void
|
|
29
|
+
}
|
|
30
|
+
readonly config: { readonly root: string; readonly logger: { readonly info: (message: string) => void } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A Vite plugin object. */
|
|
34
|
+
export interface Plugin {
|
|
35
|
+
readonly name: string
|
|
36
|
+
readonly apply: "serve"
|
|
37
|
+
readonly configureServer: (server: DevServer) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const plugin = (options: { readonly directory?: string } = {}): Plugin => ({
|
|
41
|
+
name: "ritmo-replays",
|
|
42
|
+
apply: "serve",
|
|
43
|
+
configureServer: (server) => {
|
|
44
|
+
const sink = ReplayNodeSink.make({ directory: `${server.config.root}/${options.directory ?? ".replays"}` })
|
|
45
|
+
server.middlewares.use(endpoint, (request, response, next) => {
|
|
46
|
+
if (request.method !== "POST") return next()
|
|
47
|
+
let body = ""
|
|
48
|
+
request.setEncoding("utf8")
|
|
49
|
+
request.on("data", (chunk: string) => {
|
|
50
|
+
body += chunk
|
|
51
|
+
})
|
|
52
|
+
request.on("end", () => {
|
|
53
|
+
let data: unknown
|
|
54
|
+
try {
|
|
55
|
+
data = JSON.parse(body)
|
|
56
|
+
} catch {
|
|
57
|
+
data = undefined
|
|
58
|
+
}
|
|
59
|
+
const parsed = Replay.parse(data)
|
|
60
|
+
if (!parsed.ok) {
|
|
61
|
+
response.statusCode = 400
|
|
62
|
+
response.end(parsed.error._tag === "InvalidReplay" ? parsed.error.message : parsed.error._tag)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
sink.save(parsed.value)
|
|
66
|
+
const path = sink.written.at(-1)!.slice(server.config.root.length + 1)
|
|
67
|
+
const ticks = parsed.value.ticks.length
|
|
68
|
+
server.config.logger.info(`[ritmo] replay saved: ${path} (${ticks} ticks): ${parsed.value.reason}`)
|
|
69
|
+
response.statusCode = 201
|
|
70
|
+
response.end(path)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `Replay.Sink` for a page served by Vite in development: each saved
|
|
3
|
+
* replay is posted to the dev server, where `ReplayVitePlugin` writes it
|
|
4
|
+
* into the game's repository (`.replays/`). The page's `fetch` is passed in.
|
|
5
|
+
*
|
|
6
|
+
* @module ReplayViteSink
|
|
7
|
+
* @docGroup devtools
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* if (import.meta.env.DEV) {
|
|
12
|
+
* const sink = ReplayViteSink.make({ fetch: (url, init) => fetch(url, init) })
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import type * as Replay from "./Replay.ts"
|
|
17
|
+
|
|
18
|
+
/** The dev server's endpoint for replays (what `ReplayVitePlugin` serves). */
|
|
19
|
+
export const endpoint = "/__ritmo/replays"
|
|
20
|
+
|
|
21
|
+
export interface Options {
|
|
22
|
+
/** The page's `fetch` (or any function with its shape for these two arguments). */
|
|
23
|
+
readonly fetch: (url: string, init: { readonly method: "POST"; readonly headers: Readonly<Record<string, string>>; readonly body: string }) => Promise<unknown>
|
|
24
|
+
/** Called when a post fails (the dev server is down, the plugin is not installed). */
|
|
25
|
+
readonly onError?: (error: unknown) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const make = (options: Options): Replay.Sink => ({
|
|
29
|
+
save: (file) => {
|
|
30
|
+
void options.fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(file) })
|
|
31
|
+
.catch((error: unknown) => options.onError?.(error))
|
|
32
|
+
}
|
|
33
|
+
})
|
package/src/Session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A debug session around a runtime made with `debug:
|
|
2
|
+
* A debug session around a runtime made with `debug: { hostClock }`.
|
|
3
3
|
*
|
|
4
4
|
* The session names the schedules it drives, keeps a bounded history of
|
|
5
5
|
* trace events, and answers questions about it as text and data:
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* at the first system failure, thrown defect, or violated invariant.
|
|
9
9
|
* - `journal(filter)` lists what happened, one fact per line, filtered by
|
|
10
10
|
* frames, entity, component, resource, event, machine, system, or kind.
|
|
11
|
-
* - `why(entity, component)` lists the latest changes to one component
|
|
11
|
+
* - `why(entity, component)` lists the latest changes to one component, and
|
|
12
|
+
* the systems whose declared `spawns` could have spawned the entity.
|
|
12
13
|
* - `system(name)` shows one system's declared access and recent activity.
|
|
13
14
|
* - `describe()`, `dump(filter)`, `streams()` render the core debug handle.
|
|
14
15
|
* - `report()` summarizes per-system timing and warnings accumulated while
|
|
@@ -34,10 +35,13 @@
|
|
|
34
35
|
* console.log(session.journal({ frames: [180, 185], entity: 1 }))
|
|
35
36
|
* ```
|
|
36
37
|
*/
|
|
38
|
+
import { HostClock } from "@typeonce/ritmo"
|
|
37
39
|
import type * as Debug from "@typeonce/ritmo/Debug"
|
|
40
|
+
import type * as Inspector from "@typeonce/ritmo/Inspector"
|
|
38
41
|
import type * as Machine from "@typeonce/ritmo/Machine"
|
|
39
42
|
import type * as Result from "@typeonce/ritmo/Result"
|
|
40
|
-
import type
|
|
43
|
+
import type * as Runtime from "@typeonce/ritmo/Runtime"
|
|
44
|
+
import type { AnyScheduleOrder, ExecutableScheduleDefinition } from "@typeonce/ritmo/Schedule"
|
|
41
45
|
import type { Schema } from "@typeonce/ritmo/Schema"
|
|
42
46
|
|
|
43
47
|
import * as Format from "./Format.ts"
|
|
@@ -46,17 +50,58 @@ import * as Rendered from "./Rendered.ts"
|
|
|
46
50
|
import { nonFinitePath } from "./internal/values.ts"
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
|
-
* What a session needs from a runtime: its debug handle
|
|
50
|
-
* `tryTick`, which reports missing requirements as data
|
|
53
|
+
* What a session needs from a runtime: its debug handle, the dynamic
|
|
54
|
+
* `tryTick`, which reports missing requirements as data, `inspect` for
|
|
55
|
+
* invariants, and what it was provided with, which invariants are checked
|
|
56
|
+
* against.
|
|
51
57
|
*/
|
|
52
|
-
export interface Target<
|
|
58
|
+
export interface Target<
|
|
59
|
+
S extends Schema.Any,
|
|
60
|
+
Root,
|
|
61
|
+
Services extends Record<string, unknown> = Record<string, unknown>,
|
|
62
|
+
Resources extends object = object,
|
|
63
|
+
Machines extends Record<string, unknown> = Record<string, unknown>
|
|
64
|
+
> {
|
|
53
65
|
readonly debug: Debug.Handle<S, Root>
|
|
54
|
-
readonly tryTick: (schedule: ExecutableScheduleDefinition<S, any, any, any, any>) => Result.Result<void, unknown>
|
|
66
|
+
readonly tryTick: (schedule: ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>) => Result.Result<void, unknown>
|
|
67
|
+
readonly inspect: (inspector: never) => unknown
|
|
68
|
+
readonly services: Services
|
|
69
|
+
readonly resourceValues: Resources
|
|
70
|
+
readonly machineValues: Machines
|
|
55
71
|
}
|
|
56
72
|
|
|
57
|
-
|
|
73
|
+
type InvariantRequirementErrors<
|
|
74
|
+
Invariants extends ReadonlyArray<Invariant<Invariant.CheckInspector>>,
|
|
75
|
+
Services extends Record<string, unknown>,
|
|
76
|
+
Resources extends object,
|
|
77
|
+
Machines extends Record<string, unknown>
|
|
78
|
+
> = Runtime.ValidateInspector<Invariants[number]["inspector"], Services, Resources, Machines> extends {
|
|
79
|
+
readonly __fixRuntimeRequirements__: infer Errors
|
|
80
|
+
}
|
|
81
|
+
? Errors
|
|
82
|
+
: never
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Rejects invariants whose inspectors read a resource, service, or machine
|
|
86
|
+
* the runtime does not provide, naming each one.
|
|
87
|
+
*/
|
|
88
|
+
export type ValidateInvariants<
|
|
89
|
+
Invariants extends ReadonlyArray<Invariant<Invariant.CheckInspector>>,
|
|
90
|
+
Services extends Record<string, unknown>,
|
|
91
|
+
Resources extends object,
|
|
92
|
+
Machines extends Record<string, unknown>
|
|
93
|
+
> = [InvariantRequirementErrors<Invariants, Services, Resources, Machines>] extends [never]
|
|
94
|
+
? unknown
|
|
95
|
+
: { readonly __fixInvariantRequirements__: InvariantRequirementErrors<Invariants, Services, Resources, Machines> }
|
|
96
|
+
|
|
97
|
+
export interface Options<
|
|
98
|
+
S extends Schema.Any,
|
|
99
|
+
Root,
|
|
100
|
+
Names extends string,
|
|
101
|
+
Invariants extends ReadonlyArray<Invariant<Invariant.CheckInspector>> = readonly []
|
|
102
|
+
> {
|
|
58
103
|
/** The schedules `run` can drive, by name. Also names them in traces. */
|
|
59
|
-
readonly schedules: Readonly<Record<Names, ExecutableScheduleDefinition<S, any, Root, any, any>>>
|
|
104
|
+
readonly schedules: Readonly<Record<Names, ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>
|
|
60
105
|
/**
|
|
61
106
|
* More schedules to include in `describe()`, its lints, and the access
|
|
62
107
|
* maps, without making them runnable: typically a render schedule that
|
|
@@ -64,11 +109,22 @@ export interface Options<S extends Schema.Any, Root, Names extends string> {
|
|
|
64
109
|
* and components only those schedules read are reported as never read.
|
|
65
110
|
* Their services show as not provided, which is not a lint.
|
|
66
111
|
*/
|
|
67
|
-
readonly describe?: Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any>>>
|
|
68
|
-
/**
|
|
69
|
-
readonly
|
|
112
|
+
readonly describe?: Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>
|
|
113
|
+
/** Events the host publishes with `runtime.send`: written, for the lints (see `Debug.Handle.declareHostEvents`). */
|
|
114
|
+
readonly hostEvents?: ReadonlyArray<Schema.EventDescriptor<S>>
|
|
115
|
+
/**
|
|
116
|
+
* Checked after every frame of every run, in order; the first violation
|
|
117
|
+
* stops the run. Built with `Invariant.fromInspector(Game.Inspector(...))`.
|
|
118
|
+
*/
|
|
119
|
+
readonly invariants?: Invariants
|
|
70
120
|
/** Frames of trace history kept for `journal` and `why`. Defaults to 600. */
|
|
71
121
|
readonly history?: number
|
|
122
|
+
/**
|
|
123
|
+
* Times runs (`RunResult.ms`). `HostClock.test()` makes every timing 0,
|
|
124
|
+
* so text output is identical across runs (give the runtime's
|
|
125
|
+
* `debug: { hostClock }` a test clock too).
|
|
126
|
+
*/
|
|
127
|
+
readonly hostClock: HostClock.HostClock
|
|
72
128
|
/** Value formatting for all text output. */
|
|
73
129
|
readonly format?: Format.ValueOptions
|
|
74
130
|
}
|
|
@@ -86,8 +142,24 @@ export type Stop =
|
|
|
86
142
|
| { readonly reason: "until"; readonly frame: number }
|
|
87
143
|
| { readonly reason: "failure"; readonly frame: number; readonly system: string; readonly error: unknown }
|
|
88
144
|
| { readonly reason: "missingRequirements"; readonly frame: number; readonly requirements: unknown }
|
|
145
|
+
/** A `Game.System.thisTick` read whose writer runs after it, or that has none; nothing ran. */
|
|
146
|
+
| { readonly reason: "tickOrder"; readonly frame: number; readonly issues: ReadonlyArray<string> }
|
|
89
147
|
| { readonly reason: "defect"; readonly frame: number; readonly error: unknown }
|
|
90
148
|
| { readonly reason: "invariant"; readonly frame: number; readonly invariant: string; readonly message: string }
|
|
149
|
+
/**
|
|
150
|
+
* A failed tick result nobody read (for example a setup tick the game ran
|
|
151
|
+
* before this run): `frame` is the failed tick's, `skipped` the systems
|
|
152
|
+
* that did not run then.
|
|
153
|
+
*/
|
|
154
|
+
| {
|
|
155
|
+
readonly reason: "ignored-failure"
|
|
156
|
+
readonly frame: number
|
|
157
|
+
readonly schedule: string
|
|
158
|
+
readonly system: string | undefined
|
|
159
|
+
readonly error: unknown
|
|
160
|
+
readonly skipped: ReadonlyArray<string>
|
|
161
|
+
readonly when: "next-tick" | "session-end"
|
|
162
|
+
}
|
|
91
163
|
|
|
92
164
|
export interface RunResult {
|
|
93
165
|
readonly schedule: string
|
|
@@ -165,6 +237,7 @@ export interface Warning {
|
|
|
165
237
|
| "system-failed"
|
|
166
238
|
| "non-finite-value"
|
|
167
239
|
| "population-growing"
|
|
240
|
+
| "ignored-failure"
|
|
168
241
|
readonly message: string
|
|
169
242
|
readonly count: number
|
|
170
243
|
readonly firstFrame: number
|
|
@@ -237,22 +310,32 @@ const growsSteadily = (samples: ReadonlyArray<number>): boolean => {
|
|
|
237
310
|
return true
|
|
238
311
|
}
|
|
239
312
|
|
|
240
|
-
const describeError = (error: unknown): { readonly kind?: unknown; readonly system?: unknown; readonly error?: unknown; readonly requirements?: unknown } =>
|
|
313
|
+
const describeError = (error: unknown): { readonly kind?: unknown; readonly system?: unknown; readonly error?: unknown; readonly requirements?: unknown; readonly issues?: ReadonlyArray<{ readonly message: string }> } =>
|
|
241
314
|
typeof error === "object" && error !== null ? error : {}
|
|
242
315
|
|
|
243
|
-
export const make = <
|
|
244
|
-
|
|
245
|
-
|
|
316
|
+
export const make = <
|
|
317
|
+
S extends Schema.Any,
|
|
318
|
+
Root,
|
|
319
|
+
Services extends Record<string, unknown>,
|
|
320
|
+
Resources extends object,
|
|
321
|
+
Machines extends Record<string, unknown>,
|
|
322
|
+
const Names extends string,
|
|
323
|
+
const Invariants extends ReadonlyArray<Invariant<Inspector.InspectorDefinition<any, string | undefined, Root, any, any>>> = readonly []
|
|
324
|
+
>(
|
|
325
|
+
runtime: Target<S, Root, Services, Resources, Machines>,
|
|
326
|
+
options: Options<S, Root, Names, Invariants> & ValidateInvariants<Invariants, Services, Resources, Machines>
|
|
246
327
|
): Session<S, Names> => {
|
|
328
|
+
const inspect = runtime.inspect as (inspector: Inspector.Inspector.Any) => string | undefined
|
|
247
329
|
const debug = runtime.debug
|
|
248
330
|
const capacity = options.history ?? 600
|
|
249
331
|
const format = options.format ?? {}
|
|
250
|
-
const schedules = options.schedules as Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any>>>
|
|
332
|
+
const schedules = options.schedules as Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>
|
|
251
333
|
const described = options.describe ?? {}
|
|
252
334
|
for (const name of Object.keys(described)) {
|
|
253
335
|
if (name in schedules) throw new Error(`Session: "${name}" is both a runnable and a described-only schedule`)
|
|
254
336
|
}
|
|
255
337
|
debug.nameSchedules({ ...described, ...schedules })
|
|
338
|
+
debug.declareHostEvents(options.hostEvents ?? [])
|
|
256
339
|
|
|
257
340
|
const history: Array<FrameRecord> = []
|
|
258
341
|
let current: FrameRecord = { frame: debug.frame(), events: [] }
|
|
@@ -387,6 +470,10 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
387
470
|
}
|
|
388
471
|
}
|
|
389
472
|
break
|
|
473
|
+
case "failure.ignored":
|
|
474
|
+
ignored = event
|
|
475
|
+
warn("ignored-failure", `ignored:${event.frame}`, Format.ignoredFailure(event, format), event.frame)
|
|
476
|
+
break
|
|
390
477
|
case "transition":
|
|
391
478
|
if (event.outcome !== "failed") pendingStates.delete(event.machine)
|
|
392
479
|
if (event.outcome === "failed" || event.outcome === "enterFailed") {
|
|
@@ -395,6 +482,13 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
395
482
|
break
|
|
396
483
|
}
|
|
397
484
|
}
|
|
485
|
+
/** The last `failure.ignored` event seen, for `run` to stop on. */
|
|
486
|
+
let ignored: Debug.FailureIgnoredEvent | undefined
|
|
487
|
+
const takeIgnored = (): Debug.FailureIgnoredEvent | undefined => {
|
|
488
|
+
const found = ignored
|
|
489
|
+
ignored = undefined
|
|
490
|
+
return found
|
|
491
|
+
}
|
|
398
492
|
const stopObserving = debug.observe(record)
|
|
399
493
|
|
|
400
494
|
const historyRange = (): readonly [number, number] | undefined => {
|
|
@@ -405,7 +499,7 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
405
499
|
const run = (name: Names, runOptions: RunOptions = {}): Rendered.Rendered<RunResult> => {
|
|
406
500
|
const schedule = schedules[name]!
|
|
407
501
|
const frames = runOptions.frames ?? 1
|
|
408
|
-
const started =
|
|
502
|
+
const started = options.hostClock.now()
|
|
409
503
|
const warningsBefore = new Map([...warnings].map(([key, warning]) => [key, warning.count]))
|
|
410
504
|
const initial = debug.population()
|
|
411
505
|
const entityCounts: Array<number> = []
|
|
@@ -418,6 +512,7 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
418
512
|
let first: number | undefined
|
|
419
513
|
let last: number | undefined
|
|
420
514
|
let stop: Stop = { reason: "completed" }
|
|
515
|
+
takeIgnored()
|
|
421
516
|
for (let index = 0; index < frames; index++) {
|
|
422
517
|
let result: Result.Result<void, unknown>
|
|
423
518
|
try {
|
|
@@ -433,17 +528,25 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
433
528
|
first ??= frame
|
|
434
529
|
last = frame
|
|
435
530
|
sample()
|
|
531
|
+
const unread = takeIgnored()
|
|
532
|
+
if (unread !== undefined) {
|
|
533
|
+
const { type: _type, ...found } = unread
|
|
534
|
+
stop = { reason: "ignored-failure", ...found }
|
|
535
|
+
break
|
|
536
|
+
}
|
|
436
537
|
if (!result.ok) {
|
|
437
538
|
const error = describeError(result.error)
|
|
438
539
|
stop = error.kind === "MissingRuntimeRequirements"
|
|
439
540
|
? { reason: "missingRequirements", frame, requirements: error.requirements }
|
|
440
|
-
:
|
|
541
|
+
: error.kind === "TickOrderViolation"
|
|
542
|
+
? { reason: "tickOrder", frame, issues: (error.issues ?? []).map((issue) => issue.message) }
|
|
543
|
+
: { reason: "failure", frame, system: typeof error.system === "string" ? error.system : "(unknown)", error: error.error }
|
|
441
544
|
break
|
|
442
545
|
}
|
|
443
546
|
const violation = (options.invariants ?? []).reduce<{ readonly invariant: string; readonly message: string } | undefined>(
|
|
444
547
|
(found, invariant) => {
|
|
445
548
|
if (found) return found
|
|
446
|
-
const message = invariant.
|
|
549
|
+
const message = inspect(invariant.inspector)
|
|
447
550
|
return message === undefined ? undefined : { invariant: invariant.name, message }
|
|
448
551
|
},
|
|
449
552
|
undefined
|
|
@@ -488,7 +591,7 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
488
591
|
frames: first === undefined || last === undefined ? 0 : last - first + 1,
|
|
489
592
|
range: first === undefined || last === undefined ? undefined : [first, last],
|
|
490
593
|
stop,
|
|
491
|
-
ms:
|
|
594
|
+
ms: HostClock.elapsed(started, options.hostClock.now()),
|
|
492
595
|
lintWarnings: lintWarnings.length,
|
|
493
596
|
warnings: raised,
|
|
494
597
|
entities: range(initial.entities, entityCounts),
|
|
@@ -554,12 +657,30 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
554
657
|
: component.name in currentValue.components
|
|
555
658
|
? `e${entity} ${component.name} is now ${Format.value(currentValue.components[component.name], format)}`
|
|
556
659
|
: `e${entity} has no ${component.name} now`
|
|
557
|
-
const
|
|
660
|
+
const changes = lines.length === 0
|
|
558
661
|
? `${now}; no change to it in ${rangeText()}`
|
|
559
662
|
: `${now}; last ${lines.length} changes (oldest first):`
|
|
663
|
+
const spawners = spawnersOf(currentValue === undefined ? [component.name] : Object.keys(currentValue.components), component.name)
|
|
664
|
+
const header = `${changes}\n${spawners.length === 0
|
|
665
|
+
? `no described system declares spawns of ${component.name}`
|
|
666
|
+
: `could have spawned it: ${spawners.join(", ")}`}`
|
|
560
667
|
return Rendered.make(lines, renderLines(header, lines))
|
|
561
668
|
}
|
|
562
669
|
|
|
670
|
+
/**
|
|
671
|
+
* The described systems whose declared `spawns` include `component` and
|
|
672
|
+
* cover every component the entity has that no described system may
|
|
673
|
+
* insert (so it can only have come from the spawn).
|
|
674
|
+
*/
|
|
675
|
+
const spawnersOf = (has: ReadonlyArray<string>, component: string): Array<string> => {
|
|
676
|
+
const systems = debug.describe().systems
|
|
677
|
+
const insertable = new Set(systems.flatMap((system) => system.commands.inserts))
|
|
678
|
+
const fromSpawn = has.filter((name) => !insertable.has(name))
|
|
679
|
+
return systems
|
|
680
|
+
.filter((system) => system.commands.spawns.includes(component) && fromSpawn.every((name) => system.commands.spawns.includes(name)))
|
|
681
|
+
.map((system) => system.name)
|
|
682
|
+
}
|
|
683
|
+
|
|
563
684
|
const whyResource = (resource: Schema.ResourceDescriptor<S>, whyOptions: { readonly limit?: number } = {}) => {
|
|
564
685
|
const lines = collectLines({ resource, kinds: ["resource"], limit: whyOptions.limit ?? 5 })
|
|
565
686
|
const resources = debug.dump({ limit: 0 }).resources
|
|
@@ -597,6 +718,8 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
597
718
|
}
|
|
598
719
|
|
|
599
720
|
const report = () => {
|
|
721
|
+
// The report is where a session ends: a failure still unread shows here.
|
|
722
|
+
debug.checkIgnoredFailures()
|
|
600
723
|
const systems = [...stats.keys()].map((name) => statsFor(name)!).sort((left, right) => right.totalMs - left.totalMs)
|
|
601
724
|
const data: Report = {
|
|
602
725
|
frames: debug.frame(),
|
|
@@ -644,7 +767,10 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
|
|
|
644
767
|
},
|
|
645
768
|
report,
|
|
646
769
|
frame: () => debug.frame(),
|
|
647
|
-
close:
|
|
770
|
+
close: () => {
|
|
771
|
+
debug.checkIgnoredFailures()
|
|
772
|
+
stopObserving()
|
|
773
|
+
}
|
|
648
774
|
}
|
|
649
775
|
}
|
|
650
776
|
|
|
@@ -665,5 +791,9 @@ const renderRun = (result: RunResult, format: Format.ValueOptions): string => {
|
|
|
665
791
|
return `${head}, DEFECT at f${stop.frame}: ${stop.error instanceof Error ? stop.error.stack ?? stop.error.message : Format.value(stop.error, format)}`
|
|
666
792
|
case "invariant":
|
|
667
793
|
return `${head}, INVARIANT "${stop.invariant}" violated at f${stop.frame}: ${stop.message}`
|
|
794
|
+
case "tickOrder":
|
|
795
|
+
return `${head}, NOT RUN after f${stop.frame}: ${stop.issues.join("; ")}`
|
|
796
|
+
case "ignored-failure":
|
|
797
|
+
return `${head}, STOPPED: ${Format.ignoredFailure({ type: "failure.ignored", ...stop }, format)}, at f${stop.frame}`
|
|
668
798
|
}
|
|
669
799
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `ritmo-devtools`: devtools from the command line.
|
|
4
|
+
*
|
|
5
|
+
* ritmo-devtools lint <entry>
|
|
6
|
+
*
|
|
7
|
+
* Imports `<entry>` (a module exporting `lintTarget`, see the `Lint` module),
|
|
8
|
+
* prints every lint of its schedules with its fix, and exits with 1 when one
|
|
9
|
+
* is a warning. Info-level lints are printed and do not fail.
|
|
10
|
+
*
|
|
11
|
+
* @module cli
|
|
12
|
+
* @docGroup devtools
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from "node:path"
|
|
15
|
+
import { pathToFileURL } from "node:url"
|
|
16
|
+
|
|
17
|
+
import * as Lint from "./Lint.ts"
|
|
18
|
+
|
|
19
|
+
const usage = "usage: ritmo-devtools lint <entry>\n <entry> exports lintTarget: () => { runtime, schedules, describe?, hostEvents? } (see the Lint module)"
|
|
20
|
+
|
|
21
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
|
|
22
|
+
|
|
23
|
+
/** Checks at runtime what the type of `lintTarget`'s result promises, so a wrong entry fails with a message. */
|
|
24
|
+
const isTarget = (value: unknown): value is Lint.Target<any, any> =>
|
|
25
|
+
isRecord(value)
|
|
26
|
+
&& isRecord(value["runtime"])
|
|
27
|
+
&& isRecord(value["runtime"]["debug"])
|
|
28
|
+
&& typeof value["runtime"]["debug"]["describe"] === "function"
|
|
29
|
+
&& isRecord(value["schedules"])
|
|
30
|
+
&& (value["describe"] === undefined || isRecord(value["describe"]))
|
|
31
|
+
&& (value["hostEvents"] === undefined || Array.isArray(value["hostEvents"]))
|
|
32
|
+
|
|
33
|
+
const main = async (args: ReadonlyArray<string>): Promise<number> => {
|
|
34
|
+
const [command, entry] = args
|
|
35
|
+
if (command !== "lint" || entry === undefined) {
|
|
36
|
+
console.error(usage)
|
|
37
|
+
return 2
|
|
38
|
+
}
|
|
39
|
+
const loaded: unknown = await import(pathToFileURL(resolve(entry)).href)
|
|
40
|
+
const make = isRecord(loaded) ? loaded["lintTarget"] : undefined
|
|
41
|
+
if (typeof make !== "function") {
|
|
42
|
+
console.error(`${entry} must export lintTarget: a function returning { runtime, schedules, describe? } (see the Lint module)`)
|
|
43
|
+
return 2
|
|
44
|
+
}
|
|
45
|
+
const target: unknown = await make()
|
|
46
|
+
if (!isTarget(target)) {
|
|
47
|
+
console.error(`lintTarget in ${entry} must return { runtime, schedules, describe? } with a runtime made with debug: { hostClock }`)
|
|
48
|
+
return 2
|
|
49
|
+
}
|
|
50
|
+
const report = Lint.run(target)
|
|
51
|
+
console.log(report.text)
|
|
52
|
+
return report.data.ok ? 0 : 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.exitCode = await main(process.argv.slice(2))
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** Debug sessions, trace history, text reports, and a live browser monitor for ritmo runtimes. */
|
|
2
2
|
export * as Format from "./Format.ts"
|
|
3
|
+
export * as Determinism from "./Determinism.ts"
|
|
3
4
|
export * as Invariant from "./Invariant.ts"
|
|
5
|
+
export * as Lint from "./Lint.ts"
|
|
4
6
|
export * as Monitor from "./Monitor.ts"
|
|
7
|
+
export * as Replay from "./Replay.ts"
|
|
5
8
|
export * as Rendered from "./Rendered.ts"
|
|
6
9
|
export * as Session from "./Session.ts"
|
|
7
10
|
|
package/src/internal/monitor.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface Sample {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface Alert {
|
|
48
|
-
readonly code: "system-failed" | "non-finite-value" | "discarded-messages"
|
|
48
|
+
readonly code: "system-failed" | "non-finite-value" | "discarded-messages" | "ignored-failure"
|
|
49
49
|
readonly message: string
|
|
50
50
|
readonly count: number
|
|
51
51
|
readonly firstFrame: number
|
|
@@ -100,16 +100,20 @@ export class Collector {
|
|
|
100
100
|
private readonly maxLines: number
|
|
101
101
|
private readonly population: () => Debug.Population
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
private readonly clock: () => number
|
|
104
|
+
|
|
105
|
+
/** `now` reads the host clock (milliseconds); the collector times its own work with it. */
|
|
106
|
+
constructor(population: () => Debug.Population, now: () => number, options: CollectorOptions = {}) {
|
|
104
107
|
this.population = population
|
|
105
|
-
this.
|
|
108
|
+
this.clock = now
|
|
109
|
+
this.windowStart = now()
|
|
106
110
|
this.maxHistory = options.history ?? 60
|
|
107
111
|
this.maxLines = options.entityLines ?? 40
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
/** Folds one trace event. Pass it to `debug.observe`. */
|
|
111
115
|
readonly observe = (event: Debug.TraceEvent): void => {
|
|
112
|
-
const started =
|
|
116
|
+
const started = this.clock()
|
|
113
117
|
switch (event.type) {
|
|
114
118
|
case "system":
|
|
115
119
|
this.onSystem(event)
|
|
@@ -130,6 +134,9 @@ export class Collector {
|
|
|
130
134
|
case "deferred":
|
|
131
135
|
this.onDeferred(event)
|
|
132
136
|
break
|
|
137
|
+
case "failure.ignored":
|
|
138
|
+
this.alert("ignored-failure", `ignored:${event.frame}`, Format.ignoredFailure(event), event.frame)
|
|
139
|
+
break
|
|
133
140
|
case "schedule.end":
|
|
134
141
|
if (!event.schedule.includes(" > ")) {
|
|
135
142
|
const schedule = this.scheduleOf(event.schedule)
|
|
@@ -139,7 +146,7 @@ export class Collector {
|
|
|
139
146
|
}
|
|
140
147
|
break
|
|
141
148
|
}
|
|
142
|
-
this.overhead +=
|
|
149
|
+
this.overhead += Math.max(0, this.clock() - started)
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
private windowOf(system: string): MutableWindow {
|