incanto 0.47.0 → 0.48.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.
@@ -26,6 +26,7 @@ const { listeningPorts, parseProcNetTcp } = await import(
26
26
  pathToFileURL(join(PKG, 'dist', 'vite.js')).href
27
27
  );
28
28
  const { frameText, diffText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
29
+ const { parseDrive } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
29
30
 
30
31
  const args = process.argv.slice(2);
31
32
  const asJson = args.includes('--json');
@@ -39,6 +40,8 @@ const flag = (name) => {
39
40
  const gridArg = flag('--grid');
40
41
  const outArg = flag('--out');
41
42
  const sizeArg = flag('--size');
43
+ const driveArg = flag('--do');
44
+ const driveFile = flag('--do-file');
42
45
  const remember = flag('--remember');
43
46
  const diff = flag('--diff');
44
47
  const threshold = flag('--threshold');
@@ -49,6 +52,8 @@ if (args.includes('--help') || args.includes('-h')) {
49
52
  --grid WxH cells across and down (default 16x9)
50
53
  --out FILE also write the frame as a PNG you can look at
51
54
  --size N longest side of that PNG (default 512)
55
+ --do SCRIPT drive the game first, THEN capture (see below)
56
+ --do-file FILE the same script, from a file
52
57
  --port N skip discovery and use this port
53
58
 
54
59
  --remember [L] keep this frame as the baseline named L (default "last")
@@ -64,6 +69,17 @@ the frame it fills. When that is not enough, look:
64
69
 
65
70
  incanto-frame --out shot.png # then open it, or read it
66
71
 
72
+ Every interesting state in a game is downstream of input, so --do drives the
73
+ running game before capturing — the same words incanto-play takes:
74
+
75
+ incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
76
+ incanto-frame --do "press jump; step 400" --out midair.png
77
+
78
+ press/release ACTION · vector ACTION X Y · key CODE down|up
79
+ pointer DX DY · step MS (semicolons or newlines separate)
80
+
81
+ step waits on the real clock: this is the live game, with its own loop.
82
+
67
83
 
68
84
  The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
69
85
  once a cell is averaged, and nothing can tell it apart from a thin rope in the
@@ -111,6 +127,24 @@ function bsdListeningPorts() {
111
127
  }
112
128
  }
113
129
 
130
+ /** The script to run before capturing, from `--do` or `--do-file`. */
131
+ const driveScript = driveFile ? read(driveFile) : driveArg;
132
+ if (driveFile && driveScript === null) {
133
+ console.error(`cannot read ${driveFile}`);
134
+ process.exit(1);
135
+ }
136
+ if (driveScript) {
137
+ const parsed = parseDrive(driveScript);
138
+ if (parsed.error) {
139
+ // Refuse here rather than in the page: a bad command that ran nothing would
140
+ // hand back the boot screen, and nothing about that frame would say so.
141
+ console.error(
142
+ `${parsed.error}\n commands: press/release ACTION · vector ACTION X Y · key CODE down|up · pointer DX DY · step MS`,
143
+ );
144
+ process.exit(1);
145
+ }
146
+ }
147
+
114
148
  const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
115
149
  const discovered = explicit ? [explicit] : await listeningPorts({ read });
116
150
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
@@ -150,12 +184,20 @@ if (!found) {
150
184
  const params = new URLSearchParams();
151
185
  if (gridArg !== null) params.set('grid', gridArg);
152
186
  if (outArg) params.set('image', sizeArg || '512');
187
+ if (driveScript) {
188
+ params.set('drive', driveScript);
189
+ // Tell the server how long to wait: the script runs in real time.
190
+ const totalMs = parseDrive(driveScript).totalMs;
191
+ params.set('driveMs', String(totalMs));
192
+ }
153
193
  if (remember !== null) params.set('remember', remember);
154
194
  if (diff !== null) params.set('diff', diff);
155
195
  if (threshold) params.set('threshold', threshold);
156
196
  const q = params.toString();
157
197
  const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ? `?${q}` : ''}`, {
158
- signal: AbortSignal.timeout(10_000),
198
+ // Outlast the server's own budget, or the CLI gives up on an answer that
199
+ // was on its way.
200
+ signal: AbortSignal.timeout(15_000 + (driveScript ? parseDrive(driveScript).totalMs * 2 : 0)),
159
201
  });
160
202
  const body = await res.json().catch(() => null);
161
203
 
@@ -184,6 +226,18 @@ if (!res.ok || !body?.report) {
184
226
  }
185
227
 
186
228
  const report = body.report;
229
+ // A drive that advanced no frames moved nothing: the inputs landed on a game
230
+ // the browser had frozen, and the frame that came back is the one from before.
231
+ // Saying nothing here would be handing over a picture that quietly lies.
232
+ if (driveScript && report.droveFrames === 0) {
233
+ console.error(
234
+ 'the drive ran but the game never advanced — 0 frames drawn.\n' +
235
+ ' The page is hidden (a window covered by another counts) and the browser\n' +
236
+ ' stops animating it, so the inputs landed on a frozen game and this frame\n' +
237
+ ' is unchanged. Bring the preview to the front and try again.',
238
+ );
239
+ process.exit(1);
240
+ }
187
241
  if (report.error) {
188
242
  console.error(`the page could not capture a frame: ${report.error}`);
189
243
  process.exit(1);
@@ -215,6 +269,9 @@ if (asJson) {
215
269
  );
216
270
  } else {
217
271
  const lines = [frameText(report)];
272
+ if (typeof report.droveFrames === 'number') {
273
+ lines.push(`drove ${report.droveFrames} frames before capturing`);
274
+ }
218
275
  if (wroteImage) lines.push(`wrote ${wroteImage} — open it, or read it`);
219
276
  if (body.diffError === 'no-baseline') {
220
277
  lines.push(
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-logs — what the RUNNING game is saying.
4
+ *
5
+ * bunx incanto-logs
6
+ * bunx incanto-logs --json
7
+ *
8
+ * `incanto-frame` gets the pixels out of the browser; this gets the WORDS. The
9
+ * engine writes diagnostics for exactly this purpose — a model whose fit came
10
+ * out at 934x, an asset that 404'd, a node that quarantined itself — and until
11
+ * now every one of them reached a human only, in the debug overlay's logs
12
+ * panel. An agent could see a game and not hear it.
13
+ *
14
+ * Unlike a frame this needs no render, so it answers from a tab the browser has
15
+ * stopped drawing.
16
+ *
17
+ * Exit 1 when the game reports something wrong: an error logged, an error
18
+ * swallowed to keep it alive, or an asset that never loaded.
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
+ const { listeningPorts } = await import(pathToFileURL(join(PKG, 'dist', 'vite.js')).href);
27
+ const { logText } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
28
+
29
+ const args = process.argv.slice(2);
30
+ const asJson = args.includes('--json');
31
+ if (args.includes('--help') || args.includes('-h')) {
32
+ console.error(`Usage: incanto-logs [--json] [--port N]
33
+
34
+ What the running game is saying: its warnings and errors, the errors it
35
+ swallowed to keep going, the assets that never loaded, and its frame rate.
36
+
37
+ Run your game's dev server and open the page first — the log buffer is in the
38
+ browser, not in the dev server. This needs no render, so a page the browser has
39
+ stopped drawing still answers.
40
+
41
+ Exit 1 when something is wrong.`);
42
+ process.exit(0);
43
+ }
44
+
45
+ const read = (path) => {
46
+ try {
47
+ return readFileSync(path, 'utf-8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** Ports listening on a machine with no `/proc` (macOS, BSD) — see incanto-frame. */
54
+ function bsdListeningPorts() {
55
+ try {
56
+ const { execFileSync } = createRequire(import.meta.url)('node:child_process');
57
+ const out = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
58
+ encoding: 'utf-8',
59
+ timeout: 1500,
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ });
62
+ const ports = new Set();
63
+ for (const line of out.split('\n')) {
64
+ const m = line.match(/:(\d+)\s*\(LISTEN\)/);
65
+ if (m) ports.add(Number(m[1]));
66
+ }
67
+ return [...ports];
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
74
+ const discovered = explicit ? [explicit] : await listeningPorts({ read });
75
+ const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
76
+
77
+ let found = null;
78
+ outer: for (const port of ports) {
79
+ for (const host of ['localhost', '127.0.0.1']) {
80
+ try {
81
+ const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
82
+ signal: AbortSignal.timeout(300),
83
+ });
84
+ if (!res.ok) continue;
85
+ const body = await res.json();
86
+ if (body?.incanto) {
87
+ found = { host, port };
88
+ break outer;
89
+ }
90
+ } catch {
91
+ // not listening, not ours, or not answering — next
92
+ }
93
+ }
94
+ }
95
+
96
+ if (!found) {
97
+ console.error(
98
+ 'no incanto dev server found.\n' +
99
+ ' Start your game (the dev server) and try again — the log buffer lives in\n' +
100
+ ' the running page, so there has to be one.',
101
+ );
102
+ process.exit(1);
103
+ }
104
+
105
+ const res = await fetch(`http://${found.host}:${found.port}/__incanto/logs`, {
106
+ signal: AbortSignal.timeout(10_000),
107
+ });
108
+ const body = await res.json().catch(() => null);
109
+
110
+ if (res.status === 504 || body?.error === 'no-page-connected') {
111
+ console.error(
112
+ `the dev server is running on :${found.port}, but no page answered.\n` +
113
+ ' Open the preview in a browser — the log buffer is there, not in the server.',
114
+ );
115
+ process.exit(1);
116
+ }
117
+ if (!res.ok || !body?.report || body.report.error) {
118
+ console.error(
119
+ `could not read the game's logs: ${body?.report?.error ?? body?.error ?? res.status}`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ console.log(asJson ? JSON.stringify(body.report, null, 2) : logText(body.report));
125
+ // `exitCode`, never `process.exit()` — stdout to a pipe is written
126
+ // asynchronously and exiting discards what has not flushed.
127
+ process.exitCode = body.report.ok ? 0 : 1;
@@ -13,6 +13,7 @@
13
13
  * loads incanto-check the scene is legal and every asset resolves
14
14
  * plays incanto-playtest a seeded run can actually finish it
15
15
  * draws incanto-frame the GPU drew something, and the subject is in shot
16
+ * says incanto-logs what the running game is complaining about
16
17
  *
17
18
  * Exit 1 when a rung FAILED. An unmeasured rung is not a failure — it prints
18
19
  * what to arrange, and says so in the summary.
@@ -41,6 +42,7 @@ Walks the whole ladder and tells you the ONE thing to do next:
41
42
  loads the scene is legal and every asset resolves (incanto-check)
42
43
  plays a seeded run can actually finish it (incanto-playtest)
43
44
  draws the GPU drew something and the subject is in shot (incanto-frame)
45
+ says the running game logs no errors and lost no assets (incanto-logs)
44
46
 
45
47
  Without a scene it looks for one *.scene.json under the current directory.
46
48
  "draws" needs a dev server with the page open; without one it is reported as
@@ -190,6 +192,35 @@ if (rungs[0].status === 'pass') {
190
192
  * deprecation notice lands on stdout ahead of the report, and treating that as
191
193
  * "the tool could not run" is how this reported a healthy scene as broken.
192
194
  */
195
+ // ---- says ---------------------------------------------------------------
196
+ {
197
+ const r = run('logs', ['--json']);
198
+ const report = safeJson(r.stdout);
199
+ if (!report) {
200
+ rungs.push({
201
+ name: 'says',
202
+ status: 'unmeasured',
203
+ summary: firstLine(r.stderr) ?? 'no page to ask',
204
+ fix: 'open the preview page and run this again — the log buffer is in the browser',
205
+ });
206
+ } else if (report.ok) {
207
+ rungs.push({ name: 'says', status: 'pass', summary: 'no errors, no failed assets' });
208
+ } else {
209
+ const asset = report.assetErrors?.[0];
210
+ rungs.push({
211
+ name: 'says',
212
+ status: 'fail',
213
+ // A game whose grass and water are fine still fails here when its player
214
+ // never loaded — which is the case this rung exists for, and the one
215
+ // `draws` calls healthy.
216
+ summary: asset
217
+ ? `asset ${asset.ref} never loaded: ${asset.error}`
218
+ : (report.worst?.message ?? `${report.stats?.errors ?? 0} errors were swallowed`),
219
+ fix: 'read it in full: `incanto-logs`',
220
+ });
221
+ }
222
+ }
223
+
193
224
  function safeJson(text) {
194
225
  const raw = text ?? '';
195
226
  const start = raw.indexOf('{');
package/dist/3d.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { A as Water3D, F as PhysicsBody3D, I as RigidBody3D, L as StaticBody3D, M as WaterCutout3D, N as Area3D, P as CharacterBody3D, R as Node3D, V as WATER_MAX_RIPPLES, j as WATER_CUTOUT_MAX } from "./gameplay-BQOeAid6.js";
2
2
  import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-D1b0ydTS.js";
3
- import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-D16MVIPO.js";
3
+ import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-akjPkFv5.js";
4
4
  import { a as frameSignature, n as diffSignatures, o as frameStats, r as diffText, s as frameText, t as SIGNATURE_GRID } from "./frame-report-njybhZon.js";
5
5
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
6
6
  import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-CSoGjM8P.js";
@@ -2,6 +2,7 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { n as loadScene, v as diagnose, w as registerBehavior } from "./loader-r49nDwB4.js";
3
3
  import { _ as qualityCaps, h as Engine, m as AudioPlayer, y as qualityRendering } from "./register-BSXV8T9F.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
+ import { r as parseDrive, t as logReport } from "./log-report-lxrQY9cH.js";
5
6
  import { i as resolveRendering, n as attachTouchControls } from "./touch-BoNg_MnF.js";
6
7
  import { a as openBundledEditor, i as devServerLibrary, n as pauseWhenHidden, o as poseFromRenderer, r as crossFade, t as teardown } from "./teardown-BKTCzLek.js";
7
8
  import { B as createCausticsQuad, F as PhysicsBody3D, R as Node3D, n as registerGameplayBehaviors } from "./gameplay-BQOeAid6.js";
@@ -2455,17 +2456,69 @@ async function createGame3D(opts) {
2455
2456
  if (!capture) throw new IncantoError("TREE_VIOLATION", "this renderer cannot capture frames");
2456
2457
  return capture.call(renderer);
2457
2458
  };
2459
+ /**
2460
+ * Run a drive script against the LIVE game, in real time.
2461
+ *
2462
+ * `step` waits on the wall clock rather than advancing a fixed timestep: this
2463
+ * is the running game, with its own loop, its own physics and its own
2464
+ * animation. Stepping it by hand would fight all three.
2465
+ */
2466
+ const applyDrive = async (steps) => {
2467
+ let frames = 0;
2468
+ let watching = true;
2469
+ const tick = () => {
2470
+ if (!watching) return;
2471
+ frames += 1;
2472
+ requestAnimationFrame(tick);
2473
+ };
2474
+ requestAnimationFrame(tick);
2475
+ for (const s of steps) switch (s.kind) {
2476
+ case "press":
2477
+ engine.input.pressAction(s.action);
2478
+ break;
2479
+ case "release":
2480
+ engine.input.releaseAction(s.action);
2481
+ break;
2482
+ case "vector":
2483
+ engine.input.setActionVector(s.action, s.x, s.y);
2484
+ break;
2485
+ case "key":
2486
+ engine.input.handleKey(s.code, s.down);
2487
+ break;
2488
+ case "pointer":
2489
+ engine.input.handlePointerMove(s.dx, s.dy);
2490
+ break;
2491
+ case "step":
2492
+ await new Promise((r) => setTimeout(r, s.ms));
2493
+ break;
2494
+ }
2495
+ watching = false;
2496
+ return frames;
2497
+ };
2458
2498
  const hot = import.meta.hot;
2459
2499
  if (hot) {
2460
2500
  const onFrameRequest = (data) => {
2461
2501
  const request = data;
2462
2502
  const id = request?.id;
2463
2503
  hot.send("incanto:frame-ack", { id });
2464
- captureFramePixels().then((shot) => hot.send("incanto:frame-report", {
2504
+ const drive = request?.drive ? parseDrive(request.drive) : null;
2505
+ if (drive?.error) {
2506
+ hot.send("incanto:frame-report", {
2507
+ id,
2508
+ report: { error: drive.error }
2509
+ });
2510
+ return;
2511
+ }
2512
+ let droveFrames = null;
2513
+ (drive ? applyDrive(drive.steps) : Promise.resolve(null)).then((frames) => {
2514
+ droveFrames = frames;
2515
+ return captureFramePixels();
2516
+ }).then((shot) => hot.send("incanto:frame-report", {
2465
2517
  id,
2466
2518
  report: {
2467
2519
  ...frameStats(shot.pixels, shot.width, shot.height, { grid: request?.grid }),
2468
2520
  signature: frameSignature(shot.pixels, shot.width, shot.height),
2521
+ ...droveFrames === null ? {} : { droveFrames },
2469
2522
  ...request?.image ? { image: frameImage(shot.pixels, shot.width, shot.height, (w, h) => Object.assign(document.createElement("canvas"), {
2470
2523
  width: w,
2471
2524
  height: h
@@ -2478,6 +2531,30 @@ async function createGame3D(opts) {
2478
2531
  };
2479
2532
  hot.on("incanto:frame-request", onFrameRequest);
2480
2533
  cleanups.push(() => hot.off?.("incanto:frame-request", onFrameRequest));
2534
+ const onLogsRequest = (data) => {
2535
+ const id = data?.id;
2536
+ try {
2537
+ hot.send("incanto:logs-report", {
2538
+ id,
2539
+ report: logReport({
2540
+ entries: engine.log.entries(),
2541
+ stats: {
2542
+ ...engine.stats(),
2543
+ ...renderer.stats?.() ?? {}
2544
+ },
2545
+ assetErrors: renderer.assets?.errors() ?? [],
2546
+ hidden: typeof document !== "undefined" && document.hidden
2547
+ })
2548
+ });
2549
+ } catch (error) {
2550
+ hot.send("incanto:logs-report", {
2551
+ id,
2552
+ report: { error: error instanceof Error ? error.message : String(error) }
2553
+ });
2554
+ }
2555
+ };
2556
+ hot.on("incanto:logs-request", onLogsRequest);
2557
+ cleanups.push(() => hot.off?.("incanto:logs-request", onLogsRequest));
2481
2558
  }
2482
2559
  cleanups.push(pauseWhenHidden(engine));
2483
2560
  await report(1, "ready");
package/dist/index.d.ts CHANGED
@@ -69,6 +69,106 @@ interface DebugLineSource {
69
69
  debugLines(): Float32Array | null;
70
70
  }
71
71
  //#endregion
72
+ //#region src/core/drive-script.d.ts
73
+ /**
74
+ * A little script for driving a game, in the words it already understands.
75
+ *
76
+ * `incanto-play` has driven scenes from bash for many releases — `press`,
77
+ * `vector`, `step` — and cannot show you the result, because headless has no
78
+ * pixels. `incanto-frame` has the pixels and cannot move. Between them an agent
79
+ * could only ever look at the boot screen, and every interesting state in a
80
+ * game is downstream of input: the boss room, the bridge, the moment after the
81
+ * jump.
82
+ *
83
+ * So the same vocabulary drives the RUNNING page, and the frame comes back
84
+ * after it. One language for both, or an agent has to learn two.
85
+ *
86
+ * Pure: parsing only. Who applies the steps is the page's business.
87
+ */
88
+ type DriveStep = {
89
+ kind: "press";
90
+ action: string;
91
+ } | {
92
+ kind: "release";
93
+ action: string;
94
+ } | {
95
+ kind: "vector";
96
+ action: string;
97
+ x: number;
98
+ y: number;
99
+ } | {
100
+ kind: "key";
101
+ code: string;
102
+ down: boolean;
103
+ } | {
104
+ kind: "pointer";
105
+ dx: number;
106
+ dy: number;
107
+ } | {
108
+ kind: "step";
109
+ ms: number;
110
+ };
111
+ interface DriveScript {
112
+ steps: DriveStep[];
113
+ /** Why nothing will run, or null. A refused script runs NONE of its steps. */
114
+ error: string | null;
115
+ /** Total simulated time the script asks for, ms. */
116
+ totalMs: number;
117
+ }
118
+ /**
119
+ * `vector move 0 1; step 2000` → steps, or an error naming the bad command.
120
+ *
121
+ * Semicolons and newlines both separate, so a whole plan fits in one shell
122
+ * argument and a longer one fits in a file. `#` starts a comment.
123
+ *
124
+ * A command it does not know REFUSES THE WHOLE SCRIPT. Skipping it would leave
125
+ * an agent looking at a frame that never moved, believing it had walked
126
+ * somewhere — the failure mode this exists to prevent.
127
+ */
128
+ declare function parseDrive(text: string): DriveScript;
129
+ //#endregion
130
+ //#region src/core/log-report.d.ts
131
+ interface AssetFailure {
132
+ ref: string;
133
+ url: string;
134
+ error: string;
135
+ }
136
+ interface LiveInput {
137
+ entries: readonly LogEntry[];
138
+ stats: EngineStats;
139
+ assetErrors: readonly AssetFailure[];
140
+ /**
141
+ * The page is one the browser has stopped drawing — hidden, or a window
142
+ * merely covered by another. It throttles such a tab to about a frame a
143
+ * second, and that number says nothing about the game.
144
+ */
145
+ hidden?: boolean;
146
+ }
147
+ /** One message, however many times the game said it. */
148
+ interface GroupedLog {
149
+ level: LogLevel;
150
+ message: string;
151
+ count: number;
152
+ /** When it was last said, in the page's own clock. */
153
+ lastMs: number;
154
+ }
155
+ interface LiveReport {
156
+ ok: boolean;
157
+ /** Frame rate low enough to be felt (running, and not merely throttled). */
158
+ slow: boolean;
159
+ /** The browser is throttling this page — see {@link LiveInput.hidden}. */
160
+ hidden: boolean;
161
+ counts: Record<LogLevel, number>;
162
+ grouped: GroupedLog[];
163
+ /** The most severe thing said, or null when nothing was. */
164
+ worst: GroupedLog | null;
165
+ stats: EngineStats;
166
+ assetErrors: readonly AssetFailure[];
167
+ }
168
+ declare function logReport(input: LiveInput): LiveReport;
169
+ /** The report as a person or an agent reads it: what is wrong, then the numbers. */
170
+ declare function logText(r: LiveReport): string;
171
+ //#endregion
72
172
  //#region src/core/node-path.d.ts
73
173
  /**
74
174
  * Godot-style NodePath grammar:
@@ -710,4 +810,4 @@ declare function computeViewport(canvasW: number, canvasH: number, viewport: {
710
810
  /** Engine version. Kept in sync with package.json by the release pipeline. */
711
811
  declare const VERSION: string;
712
812
  //#endregion
713
- export { AudioBuses, type AudioElementLike, AudioPlayer, BASE_LOCALE, Behavior, type BehaviorCtor, type BehaviorState, type BusName, CONST_REF_KEY, type ComputedViewport, type ConnectionJson, type CrossfadeGains, type DebugLineSource, type DeviceHints, Engine, type EngineOptions, type EngineStats, type FindPathOptions, type GameStats, type HudAnchor, HudLayer, IncantoError, type IncantoErrorCode, type IncantoErrorDetails, InputMap, type JsonKind, type JsonObject, type JsonValue, type Listener, type LoadSceneOptions, type LocaleTables, Localization, type LogEntry, type LogLevel, LogManager, type MusicBackend, MusicManager, type MusicTrack, Node, type NodeCtor, type NodeJson, type NodeLifecycle, ORDER_GROUP_BASE, type OrderGroup, type OrderGroupTable, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, type ParsedNodePath, type ParticlePresetValues, ParticleSim, type ParticleSimConfig, type ParticleView, type PathGrid, type PlayMusicOptions, type PreloadResult, type PropDef, type PropSchema, type QualityTier, ROLLOFF_MODELS, type Recorder, type RendererStats, type ReplayEvent, type ReplayJson, type ResolvedRendering, type ResolvedViewport, type RestoreReport, Rng, type RolloffModel, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, type SaveSlot, SaveSlots, type SaveStore, Scene, type SceneJson, SceneTree, type Scheduler, Settings, type SettingsValues, SfxEngine, type SfxParams, type SfxPlayOptions, type SfxWave, Signal, type SignalListener, type SpatialParams, type SynthOptions, T_PREFIX, Timer, TouchControls, type TouchControlsOptions, UiBanner, UiBar, UiButton, UiDialogue, UiImage, UiPanel, UiSelect, UiSlider, UiText, UiToggle, VERSION, type Vec3, type ViewportFit, type Voice, type VoicePreset, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, mergeStaticSignals, newUid, parseNodePath, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveOrderGroups, resolveRendering, resolveViewport, restoreBehaviors, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
813
+ export { type AssetFailure, AudioBuses, type AudioElementLike, AudioPlayer, BASE_LOCALE, Behavior, type BehaviorCtor, type BehaviorState, type BusName, CONST_REF_KEY, type ComputedViewport, type ConnectionJson, type CrossfadeGains, type DebugLineSource, type DeviceHints, type DriveScript, type DriveStep, Engine, type EngineOptions, type EngineStats, type FindPathOptions, type GameStats, type GroupedLog, type HudAnchor, HudLayer, IncantoError, type IncantoErrorCode, type IncantoErrorDetails, InputMap, type JsonKind, type JsonObject, type JsonValue, type Listener, type LiveInput, type LiveReport, type LoadSceneOptions, type LocaleTables, Localization, type LogEntry, type LogLevel, LogManager, type MusicBackend, MusicManager, type MusicTrack, Node, type NodeCtor, type NodeJson, type NodeLifecycle, ORDER_GROUP_BASE, type OrderGroup, type OrderGroupTable, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, type ParsedNodePath, type ParticlePresetValues, ParticleSim, type ParticleSimConfig, type ParticleView, type PathGrid, type PlayMusicOptions, type PreloadResult, type PropDef, type PropSchema, type QualityTier, ROLLOFF_MODELS, type Recorder, type RendererStats, type ReplayEvent, type ReplayJson, type ResolvedRendering, type ResolvedViewport, type RestoreReport, Rng, type RolloffModel, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, type SaveSlot, SaveSlots, type SaveStore, Scene, type SceneJson, SceneTree, type Scheduler, Settings, type SettingsValues, SfxEngine, type SfxParams, type SfxPlayOptions, type SfxWave, Signal, type SignalListener, type SpatialParams, type SynthOptions, T_PREFIX, Timer, TouchControls, type TouchControlsOptions, UiBanner, UiBar, UiButton, UiDialogue, UiImage, UiPanel, UiSelect, UiSlider, UiText, UiToggle, VERSION, type Vec3, type ViewportFit, type Voice, type VoicePreset, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, logReport, logText, mergeStaticSignals, newUid, parseDrive, parseNodePath, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveOrderGroups, resolveRendering, resolveViewport, restoreBehaviors, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@ import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { n as startRecording, r as auditScene, t as replay } from "./replay-DilbZgQI.js";
6
6
  import { n as jsonEquals, r as jsonKind, t as jsonClone } from "./json-BLk7H2Qa.js";
7
7
  import { a as getNodeSignals, c as mergeStaticSignals, i as getNodeSchema, l as registerNode, n as clearRegistry, o as getNodeType, r as createNode, u as registeredTypes } from "./registry-IyWCGe4q.js";
8
+ import { n as logText, r as parseDrive, t as logReport } from "./log-report-lxrQY9cH.js";
8
9
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
9
10
  import { i as applyParticlePreset, n as PARTICLE_PRESETS, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-Bw7hB93B.js";
10
- import { a as findPath, i as preloadUrls, n as newUid, o as gridFromRows, r as assetUrls, t as VERSION } from "./src-Ca3oV1fe.js";
11
+ import { a as findPath, i as preloadUrls, n as newUid, o as gridFromRows, r as assetUrls, t as VERSION } from "./src-Bl1Kire1.js";
11
12
  import { i as resolveRendering, n as attachTouchControls, r as joystickVector, t as TouchControls } from "./touch-BoNg_MnF.js";
12
13
  import { t as duplicateNode } from "./duplicate-CRtihGmC.js";
13
- export { AudioBuses, AudioPlayer, BASE_LOCALE, Behavior, CONST_REF_KEY, Engine, HudLayer, IncantoError, InputMap, Localization, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, SaveSlots, Scene, SceneTree, Settings, SfxEngine, Signal, T_PREFIX, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiImage, UiPanel, UiSelect, UiSlider, UiText, UiToggle, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, mergeStaticSignals, newUid, parseNodePath, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveOrderGroups, resolveRendering, resolveViewport, restoreBehaviors, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
14
+ export { AudioBuses, AudioPlayer, BASE_LOCALE, Behavior, CONST_REF_KEY, Engine, HudLayer, IncantoError, InputMap, Localization, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, SaveSlots, Scene, SceneTree, Settings, SfxEngine, Signal, T_PREFIX, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiImage, UiPanel, UiSelect, UiSlider, UiText, UiToggle, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, logReport, logText, mergeStaticSignals, newUid, parseDrive, parseNodePath, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveOrderGroups, resolveRendering, resolveViewport, restoreBehaviors, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
Binary file
package/dist/react.js CHANGED
@@ -156,7 +156,7 @@ function IncantoCanvas(props) {
156
156
  pointer: latest.pointer,
157
157
  ...keyboard !== void 0 ? { keyboard } : {}
158
158
  };
159
- const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-D16MVIPO.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-CniOiWzN.js").then((n) => n.n)).createGame2D(o)))(opts);
159
+ const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-akjPkFv5.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-CniOiWzN.js").then((n) => n.n)).createGame2D(o)))(opts);
160
160
  if (disposed) {
161
161
  next.dispose();
162
162
  return;
@@ -161,6 +161,6 @@ function newUid() {
161
161
  //#endregion
162
162
  //#region src/index.ts
163
163
  /** Engine version. Kept in sync with package.json by the release pipeline. */
164
- const VERSION = "0.47.0";
164
+ const VERSION = "0.48.0";
165
165
  //#endregion
166
166
  export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
package/dist/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as VERSION } from "./src-Ca3oV1fe.js";
1
+ import { t as VERSION } from "./src-Bl1Kire1.js";
2
2
  import { n as diffSignatures } from "./frame-report-njybhZon.js";
3
3
  import { s as validateScene } from "./test-E4-otKqK.js";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
@@ -60,6 +60,7 @@ function serveFrameEndpoints(host, version, opts = {}) {
60
60
  for (const c of channels) {
61
61
  c.on("incanto:frame-report", onReport);
62
62
  c.on("incanto:frame-ack", onAck);
63
+ c.on("incanto:logs-report", onReport);
63
64
  }
64
65
  host.middlewares.use("/__incanto/ping", (_req, res) => {
65
66
  json$1(res, 200, {
@@ -67,6 +68,7 @@ function serveFrameEndpoints(host, version, opts = {}) {
67
68
  frame: Boolean(channel)
68
69
  });
69
70
  });
71
+ serveLogs(host, channels, waiting, () => nextId++, timeoutMs);
70
72
  host.middlewares.use("/__incanto/frame", (req, res) => {
71
73
  if (!channel) {
72
74
  json$1(res, 503, { error: "no-hmr-channel" });
@@ -78,11 +80,13 @@ function serveFrameEndpoints(host, version, opts = {}) {
78
80
  const threshold = Number(params.get("threshold"));
79
81
  const grid = parseGrid(params.get("grid"));
80
82
  const image = Math.max(0, Math.min(2048, Number(params.get("image")) || 0));
83
+ const drive = params.get("drive") ?? "";
84
+ const driveMs = Math.max(0, Math.min(12e4, Number(params.get("driveMs")) || 0));
81
85
  const id = nextId++;
82
86
  const timer = setTimeout(() => {
83
87
  waiting.delete(id);
84
88
  json$1(res, 504, { error: acked.delete(id) ? "page-not-drawing" : "no-page-connected" });
85
- }, timeoutMs);
89
+ }, timeoutMs + (driveMs > 0 ? driveMs * 2 + 2e3 : 0));
86
90
  waiting.set(id, (value) => {
87
91
  clearTimeout(timer);
88
92
  acked.delete(id);
@@ -113,11 +117,44 @@ function serveFrameEndpoints(host, version, opts = {}) {
113
117
  data: {
114
118
  id,
115
119
  grid,
116
- image
120
+ image,
121
+ ...drive ? { drive } : {}
117
122
  }
118
123
  });
119
124
  });
120
125
  }
126
+ /**
127
+ * `/__incanto/logs` — what the game is SAYING, not what it looks like.
128
+ *
129
+ * Same round trip as a frame and a different question. It needs no render, so
130
+ * unlike a frame it answers from a tab the browser has stopped drawing.
131
+ */
132
+ function serveLogs(host, channels, waiting, nextId, timeoutMs) {
133
+ host.middlewares.use("/__incanto/logs", (_req, res) => {
134
+ if (channels.length === 0) {
135
+ json$1(res, 503, { error: "no-hmr-channel" });
136
+ return;
137
+ }
138
+ const id = nextId();
139
+ const timer = setTimeout(() => {
140
+ waiting.delete(id);
141
+ json$1(res, 504, { error: "no-page-connected" });
142
+ }, timeoutMs);
143
+ waiting.set(id, (value) => {
144
+ clearTimeout(timer);
145
+ if (!value.ok) {
146
+ json$1(res, 500, { error: "report-failed" });
147
+ return;
148
+ }
149
+ json$1(res, 200, { report: value.report });
150
+ });
151
+ for (const c of channels) c.send({
152
+ type: "custom",
153
+ event: "incanto:logs-request",
154
+ data: { id }
155
+ });
156
+ });
157
+ }
121
158
  function query(req) {
122
159
  const url = req?.originalUrl ?? req?.url ?? "";
123
160
  const q = url.indexOf("?");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -99,6 +99,7 @@
99
99
  "incanto-feel": "bin/incanto-feel.mjs",
100
100
  "incanto-new": "bin/incanto-new.mjs",
101
101
  "incanto-frame": "bin/incanto-frame.mjs",
102
- "incanto-verify": "./bin/incanto-verify.mjs"
102
+ "incanto-verify": "./bin/incanto-verify.mjs",
103
+ "incanto-logs": "./bin/incanto-logs.mjs"
103
104
  }
104
105
  }
@@ -348,6 +348,32 @@ ceiling — drive `engine.timeScale`, with `0.25× 0.5× 1× 2×` presets:
348
348
  number is refused and negatives clamp to 0: the scale multiplies every dt in
349
349
  the engine, so one NaN would take physics, timers and animation with it.
350
350
 
351
+ ### `bunx incanto-logs` — what the game is SAYING
352
+
353
+ `incanto-frame` gets the pixels out of the browser; this gets the words. The
354
+ engine writes diagnostics for exactly this — and until they had a channel out,
355
+ they reached a human only, in the debug overlay's logs panel.
356
+
357
+ ```
358
+ $ bunx incanto-logs
359
+ asset $characters/base never loaded: … responded with 404
360
+ [error] incanto: failed to load model '…/NO-SUCH-MODEL.glb'
361
+ 82 nodes · frame rate not measurable — the page is hidden (a window covered by
362
+ another counts), and the browser throttles it. Bring it to the front
363
+ ```
364
+
365
+ That game's grass, water and trees all rendered — `incanto-frame` called it
366
+ healthy (`subject fills 84.2%`) because they ARE there. Only the player was
367
+ missing. **Pixels and words answer different questions; a game can pass one and
368
+ fail the other**, which is why both are rungs.
369
+
370
+ It needs no render, so it answers from a page the browser has stopped drawing.
371
+ Exit 1 when something is wrong: an error logged, an error swallowed to keep the
372
+ game alive, or an asset that never loaded.
373
+
374
+ A repeated warning is folded into one line with a count (`×600`) — a game
375
+ shouting the same thing every frame has one problem, not six hundred.
376
+
351
377
  ### `bunx incanto-frame` — what is actually on screen
352
378
 
353
379
  The rung above framing. `framing` PREDICTS what the camera should see from the
@@ -386,6 +412,33 @@ browser, not in the dev server.** You do not have to tell it where anything is
386
412
  it reads the ports that are actually listening, asks each one whether it is an
387
413
  incanto dev server, and asks that page for a frame over vite's own HMR channel.
388
414
 
415
+ #### Drive it somewhere first
416
+
417
+ Every interesting state in a game is downstream of input, and the boot screen is
418
+ the only frame anything could capture before this:
419
+
420
+ ```
421
+ $ bunx incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
422
+ frame 1280×720 · luminance 0.68
423
+ subject fills 21.4% of frame
424
+ drove 180 frames before capturing
425
+ wrote bridge.png — open it, or read it
426
+ ```
427
+
428
+ The same words `incanto-play` takes — `press`/`release ACTION`, `vector ACTION X Y`,
429
+ `key CODE down|up`, `pointer DX DY`, `step MS`, separated by semicolons or
430
+ newlines. `--do-file` reads a longer plan from a file. One vocabulary for the
431
+ headless gamepad and the live one.
432
+
433
+ `step` waits on the REAL clock: this is the running game, with its own loop, its
434
+ own physics and its own animation, and stepping it by hand would fight all three.
435
+
436
+ **`drove N frames` is the number to check.** A browser stops animating a tab it
437
+ considers hidden — including a window merely covered by another — and the inputs
438
+ then land on a game that never advances: the capture comes back UNCHANGED and
439
+ looks perfectly healthy. `drove 0 frames` is refused with an error rather than
440
+ handed over as a picture.
441
+
389
442
  #### Look at it
390
443
 
391
444
  ```
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.47.0",
17
+ "incanto": "^0.48.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.47.0",
16
+ "incanto": "^0.48.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.47.0",
16
+ "incanto": "^0.48.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {