incanto 0.46.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.
@@ -44,6 +44,98 @@ function cellMeans(pixels, width, height, cols, rows) {
44
44
  }
45
45
  return out;
46
46
  }
47
+ /**
48
+ * How far a pixel must sit from the background colour to count as subject.
49
+ * Chosen above the banding in a sky gradient and well below any real material.
50
+ */
51
+ const SUBJECT_DELTA = 24;
52
+ /** Subject smaller than this is present but not visible — say so differently. */
53
+ const TINY_COVERAGE = .01;
54
+ /**
55
+ * The background colour, read off the BORDER rather than voted for.
56
+ *
57
+ * A subject filling most of the shot would win a popularity contest and the
58
+ * report would invert — the sky would become "the thing", and a well-framed
59
+ * character would read as an empty frame. The edge of a frame is background in
60
+ * every shot this is asked about; where it is not, the subject is clipped, and
61
+ * that gets reported too.
62
+ */
63
+ function borderColour(pixels, width, height) {
64
+ let r = 0;
65
+ let g = 0;
66
+ let b = 0;
67
+ let n = 0;
68
+ const take = (x, y) => {
69
+ const o = (y * width + x) * 4;
70
+ r += pixels[o];
71
+ g += pixels[o + 1];
72
+ b += pixels[o + 2];
73
+ n += 1;
74
+ };
75
+ for (let x = 0; x < width; x++) {
76
+ take(x, 0);
77
+ take(x, height - 1);
78
+ }
79
+ for (let y = 1; y < height - 1; y++) {
80
+ take(0, y);
81
+ take(width - 1, y);
82
+ }
83
+ return n === 0 ? [
84
+ 0,
85
+ 0,
86
+ 0
87
+ ] : [
88
+ r / n,
89
+ g / n,
90
+ b / n
91
+ ];
92
+ }
93
+ /** Where the non-background pixels are, or null when there are none worth it. */
94
+ function findSubject(pixels, width, height) {
95
+ const global = borderColour(pixels, width, height);
96
+ const at = (x, y) => {
97
+ const o = (y * width + x) * 4;
98
+ return [
99
+ pixels[o],
100
+ pixels[o + 1],
101
+ pixels[o + 2]
102
+ ];
103
+ };
104
+ const dist = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
105
+ let minX = width;
106
+ let minY = height;
107
+ let maxX = -1;
108
+ let maxY = -1;
109
+ let count = 0;
110
+ for (let y = 0; y < height; y++) {
111
+ const left = at(0, y);
112
+ const right = at(width - 1, y);
113
+ const row = dist(left, right) > SUBJECT_DELTA ? dist(left, global) <= dist(right, global) ? left : right : [
114
+ (left[0] + right[0]) / 2,
115
+ (left[1] + right[1]) / 2,
116
+ (left[2] + right[2]) / 2
117
+ ];
118
+ for (let x = 0; x < width; x++) {
119
+ if (dist(at(x, y), row) < SUBJECT_DELTA) continue;
120
+ count += 1;
121
+ if (x < minX) minX = x;
122
+ if (x > maxX) maxX = x;
123
+ if (y < minY) minY = y;
124
+ if (y > maxY) maxY = y;
125
+ }
126
+ }
127
+ if (maxX < 0) return null;
128
+ return {
129
+ box: {
130
+ x: minX,
131
+ y: minY,
132
+ w: maxX - minX + 1,
133
+ h: maxY - minY + 1
134
+ },
135
+ coverage: count / (width * height),
136
+ clipped: minX === 0 || minY === 0 || maxX === width - 1 || maxY === height - 1
137
+ };
138
+ }
47
139
  function frameStats(pixels, width, height, opts = {}) {
48
140
  const [cols, rows] = opts.grid ?? [16, 9];
49
141
  const means = cellMeans(pixels, width, height, cols, rows);
@@ -70,21 +162,30 @@ function frameStats(pixels, width, height, opts = {}) {
70
162
  }
71
163
  const cells = cols * rows;
72
164
  const meanLuma = total / cells;
165
+ const black = maxLuma <= BLACK_LEVEL;
73
166
  return {
74
167
  width,
75
168
  height,
76
- black: maxLuma <= BLACK_LEVEL,
169
+ black,
77
170
  uniform: maxLuma - minLuma < UNIFORM_EPSILON,
78
171
  meanLuma: Math.round(meanLuma * 1e3) / 1e3,
79
- grid
172
+ grid,
173
+ subject: black ? null : findSubject(pixels, width, height)
80
174
  };
81
175
  }
176
+ const pct = (v) => `${(v * 100).toFixed(1)}%`;
82
177
  /** The report a person or an agent reads. Verdict first, numbers after. */
83
178
  function frameText(stats) {
84
179
  const lines = [];
85
180
  if (stats.black) lines.push("BLACK SCREEN — nothing was drawn (no light, no camera, or nothing in view)");
86
181
  else if (stats.uniform) lines.push("one flat colour — the camera may be inside geometry, or only the sky is drawn");
182
+ else if (!stats.subject) lines.push("nothing but background — the camera is pointed away from everything in the scene");
183
+ else if (stats.subject.coverage < TINY_COVERAGE) lines.push(`the subject fills almost nothing (${pct(stats.subject.coverage)}) — the camera is too far back, or what you meant to see is not what got drawn`);
87
184
  lines.push(`frame ${stats.width}×${stats.height} · luminance ${stats.meanLuma.toFixed(2)}`);
185
+ if (stats.subject) {
186
+ const { box, coverage, clipped } = stats.subject;
187
+ lines.push(`subject ${box.w}×${box.h} at ${box.x},${box.y} · fills ${pct(coverage)} of frame` + (clipped ? " · CLIPPED by the frame edge" : ""));
188
+ }
88
189
  return lines.join("\n");
89
190
  }
90
191
  /**
@@ -193,5 +294,31 @@ function fromBase64(text) {
193
294
  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
194
295
  return out;
195
296
  }
297
+ /**
298
+ * A `data:` PNG of the frame, scaled to fit `maxSide`.
299
+ *
300
+ * Rows are taken as they come, TOP-DOWN. `gl.readPixels` is bottom-up, which
301
+ * is why this wants flipping — but `Renderer3D.captureFrame` already flipped
302
+ * once, "rather than in each of" its consumers, and this is one of them.
303
+ * Flipping again shipped an upside-down picture that the unit test happily
304
+ * confirmed, because the test asserted the belief instead of the boundary.
305
+ */
306
+ function frameImage(pixels, width, height, makeCanvas, maxSide = 512) {
307
+ const full = makeCanvas(width, height);
308
+ const ctx = full.getContext("2d");
309
+ if (!ctx) return null;
310
+ const image = ctx.createImageData(width, height);
311
+ image.data.set(pixels.subarray(0, width * height * 4));
312
+ ctx.putImageData(image, 0, 0);
313
+ const scale = Math.min(1, maxSide / Math.max(width, height));
314
+ if (scale >= 1) return full.toDataURL("image/png");
315
+ const w = Math.max(1, Math.round(width * scale));
316
+ const h = Math.max(1, Math.round(height * scale));
317
+ const small = makeCanvas(w, h);
318
+ const sctx = small.getContext("2d");
319
+ if (!sctx) return full.toDataURL("image/png");
320
+ sctx.drawImage(full, 0, 0, w, h);
321
+ return small.toDataURL("image/png");
322
+ }
196
323
  //#endregion
197
- export { frameStats as a, frameSignature as i, diffSignatures as n, frameText as o, diffText as r, SIGNATURE_GRID as t };
324
+ export { frameSignature as a, frameImage as i, diffSignatures as n, frameStats as o, diffText as r, frameText as s, SIGNATURE_GRID as t };
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-B43b6bZo.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-BtCNdkjI.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.46.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 };
@@ -345,12 +345,14 @@ function makeOracle(engine, player) {
345
345
  let outcome = null;
346
346
  let damage = 0;
347
347
  const offs = [];
348
+ let declaresEnd = false;
348
349
  /** First verdict wins — a run ends at its first conclusion, not its last. */
349
350
  const settle = (verdict) => {
350
351
  if (outcome === null) outcome = verdict;
351
352
  };
352
353
  const walk = (node) => {
353
354
  if (node.behavior) for (const signal of node.declaredSignalNames()) {
355
+ if (signal === "won" || signal === "lost" || signal === "flowChanged") declaresEnd = true;
354
356
  if (signal === "won") offs.push(node.on(signal, () => settle("won")));
355
357
  if (signal === "lost") offs.push(node.on(signal, () => settle("lost")));
356
358
  if (signal === "died" && node === player) offs.push(node.on(signal, () => settle("lost")));
@@ -368,6 +370,7 @@ function makeOracle(engine, player) {
368
370
  if (root) walk(root);
369
371
  return {
370
372
  outcome: () => outcome,
373
+ declaresEnd: () => declaresEnd,
371
374
  damage: () => damage,
372
375
  dispose: () => {
373
376
  for (const off of offs) off();
@@ -549,7 +552,8 @@ async function runOnce(json, seed, opts) {
549
552
  return {
550
553
  run,
551
554
  targets,
552
- signals
555
+ signals,
556
+ declaresEnd: oracle.declaresEnd()
553
557
  };
554
558
  }
555
559
  /** Play the scene `runs` times and report what happened. */
@@ -559,11 +563,13 @@ async function playtest(json, opts = {}) {
559
563
  const runs = [];
560
564
  let targets = [];
561
565
  let signals = [];
566
+ let declaresWin = false;
562
567
  for (let i = 0; i < count; i++) {
563
568
  const one = await runOnce(json, base + i, opts);
564
569
  runs.push(one.run);
565
570
  targets = one.targets;
566
571
  signals = one.signals;
572
+ declaresWin = declaresWin || one.declaresEnd;
567
573
  }
568
574
  const probe = await createPlaySession(json, {
569
575
  seed: base,
@@ -577,7 +583,8 @@ async function playtest(json, opts = {}) {
577
583
  targets,
578
584
  signals: [...new Set(signals)],
579
585
  actions,
580
- inertActions: []
586
+ inertActions: [],
587
+ declaresWin
581
588
  };
582
589
  }
583
590
  function pct(n, total) {
@@ -603,7 +610,7 @@ function playtestText(report) {
603
610
  lines.push("");
604
611
  const won = runs.filter((r) => r.outcome === "won");
605
612
  if (won.length > 0) lines.push(` ✓ reached "won" in ${pct(won.length, total)} median ${Math.round(median(won.map((r) => r.timeMs)) / 1e3)}s`);
606
- else lines.push(` ✗ never reached "won" in ${total} runs — nothing in this scene declares a win, or it cannot be reached`);
613
+ else lines.push(report.declaresWin ? ` ✗ never reached "won" in ${total} runs — a win IS declared, and no run got to it` : ` · no win declared in this scene nothing emits won/lost, so there is nothing to reach`);
607
614
  for (const bad of [
608
615
  "error",
609
616
  "fell",
@@ -1139,6 +1146,46 @@ function feelText(report) {
1139
1146
  return lines.join("\n");
1140
1147
  }
1141
1148
  //#endregion
1149
+ //#region src/test/verify-ladder.ts
1150
+ const MARK = {
1151
+ pass: "✓",
1152
+ fail: "✗",
1153
+ unmeasured: "?",
1154
+ skipped: "·"
1155
+ };
1156
+ function ladderVerdict(rungs, ctx = {}) {
1157
+ const failed = rungs.find((r) => r.status === "fail");
1158
+ const unmeasured = rungs.filter((r) => r.status === "unmeasured");
1159
+ const next = failed ? failed.fix ?? failed.summary : unmeasured[0]?.fix ?? unmeasured[0]?.summary ?? null;
1160
+ const ambiguous = rungs.length === 0 && (ctx.candidates?.length ?? 0) > 1;
1161
+ return {
1162
+ ok: !failed,
1163
+ rungs,
1164
+ unmeasured: unmeasured.map((r) => r.name),
1165
+ next: ambiguous ? "name the one you mean: `incanto-verify <scene.json>`" : next ?? null,
1166
+ candidates: ctx.candidates ?? []
1167
+ };
1168
+ }
1169
+ /** The ladder as a person or an agent reads it: every rung, then the one action. */
1170
+ function ladderText(v) {
1171
+ if (v.rungs.length === 0) {
1172
+ if (v.candidates.length > 1) return [
1173
+ `${v.candidates.length} scenes here, and guessing between them would verify the wrong one:`,
1174
+ ...v.candidates.map((c) => ` ${c}`),
1175
+ "",
1176
+ `next: ${v.next}`
1177
+ ].join("\n");
1178
+ return "nothing to verify — no scene was given and none was found";
1179
+ }
1180
+ const lines = v.rungs.map((r) => `${MARK[r.status]} ${r.name} — ${r.summary}`);
1181
+ lines.push("");
1182
+ if (!v.ok) lines.push("NOT verified.");
1183
+ else if (v.unmeasured.length > 0) lines.push(`passes what was measured — ${v.unmeasured.join(", ")} not measured.`);
1184
+ else lines.push("verified: it loads, it plays, and it draws.");
1185
+ if (v.next) lines.push(`next: ${v.next}`);
1186
+ return lines.join("\n");
1187
+ }
1188
+ //#endregion
1142
1189
  //#region src/test/index.ts
1143
1190
  /**
1144
1191
  * incanto/test — the browserless verification harness.
@@ -1524,4 +1571,4 @@ async function createPlaySession(json, opts = {}) {
1524
1571
  };
1525
1572
  }
1526
1573
  //#endregion
1527
- export { framingText as _, registerAllNodes as a, feelReport as c, facingText as d, failingReplays as f, describeFraming as g, playtestText as h, findFloatingProps as i, feelText as l, playtest as m, createPlaySession as n, runScript as o, findPlayer as p, describeCapture as r, validateScene as s, captureScene as t, facingReport as u };
1574
+ export { playtestText as _, registerAllNodes as a, ladderText as c, feelText as d, facingReport as f, playtest as g, findPlayer as h, findFloatingProps as i, ladderVerdict as l, failingReplays as m, createPlaySession as n, runScript as o, facingText as p, describeCapture as r, validateScene as s, captureScene as t, feelReport as u, describeFraming as v, framingText as y };
package/dist/test.d.ts CHANGED
@@ -165,6 +165,15 @@ interface PlaytestReport {
165
165
  actions: string[];
166
166
  /** Actions that never changed the world in any run. */
167
167
  inertActions: string[];
168
+ /**
169
+ * Does anything in this scene declare an END?
170
+ *
171
+ * "No run reached a win" has two causes that need opposite fixes: a goal
172
+ * that cannot be reached, and a scene with no goal in it. A walkabout
173
+ * template is not a broken game, and reporting one as a failure sends its
174
+ * author looking for a bug that was never there.
175
+ */
176
+ declaresWin: boolean;
168
177
  }
169
178
  /**
170
179
  * Who the driver is playing as.
@@ -190,6 +199,51 @@ declare function failingReplays(report: PlaytestReport): Array<{
190
199
  replay: JsonValue;
191
200
  }>;
192
201
  //#endregion
202
+ //#region src/test/verify-ladder.d.ts
203
+ /**
204
+ * The verification ladder, as one answer.
205
+ *
206
+ * The rungs already exist — `incanto-check` says the scene loads,
207
+ * `incanto-playtest` says it can be finished, `incanto-frame` says something
208
+ * was drawn. They are documented together and used apart, because using them
209
+ * together means knowing three things nobody writes down:
210
+ *
211
+ * the ORDER (a scene that does not load cannot be played or drawn, so the
212
+ * first red rung is the only one worth reading)
213
+ * that an unmeasured rung is not a failing one — "no dev server" means the
214
+ * question was never asked, and answering it "fail" sends an agent editing
215
+ * a scene that is fine
216
+ * what to do NEXT, which is one sentence and never the whole report
217
+ *
218
+ * Pure: the rungs are run by the CLI, this decides what they add up to.
219
+ */
220
+ type RungStatus = "pass" | "fail" | "unmeasured" | "skipped";
221
+ interface RungResult {
222
+ name: string;
223
+ status: RungStatus;
224
+ /** One line: what this rung found. */
225
+ summary: string;
226
+ /** What to do about it, when there is something to do. */
227
+ fix?: string;
228
+ }
229
+ interface LadderVerdict {
230
+ ok: boolean;
231
+ rungs: RungResult[];
232
+ /** Rungs that could not be measured — questions unasked, not answers. */
233
+ unmeasured: string[];
234
+ /** The single next action, or null when there is nothing to do. */
235
+ next: string | null;
236
+ /** Scenes found when none was named (empty once one is being verified). */
237
+ candidates: string[];
238
+ }
239
+ interface LadderContext {
240
+ /** Scenes found when none was named — none, or more than one to choose from. */
241
+ candidates?: string[];
242
+ }
243
+ declare function ladderVerdict(rungs: RungResult[], ctx?: LadderContext): LadderVerdict;
244
+ /** The ladder as a person or an agent reads it: every rung, then the one action. */
245
+ declare function ladderText(v: LadderVerdict): string;
246
+ //#endregion
193
247
  //#region src/test/index.d.ts
194
248
  /** One thing that is not standing where it should be. */
195
249
  interface GroundingIssue {
@@ -371,4 +425,4 @@ interface PlaySession {
371
425
  */
372
426
  declare function createPlaySession(json: unknown, opts?: PlaySessionOptions): Promise<PlaySession>;
373
427
  //#endregion
374
- export { type FacingOptions, type FacingReport, type FeelOptions, type FeelReport, type FramingEntry, type FramingOptions, type FramingReport, GroundingIssue, GroundingOptions, NodeCapture, type Outcome, PlaySession, PlaySessionOptions, type PlaytestOptions, type PlaytestReport, type PlaytestRun, RunContext, RunFailure, RunResult, RunScriptOptions, SceneCapture, ScriptStep, ValidateSceneOptions, ValidationResult, type Where, auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, playtest, playtestText, registerAllNodes, runScript, validateScene };
428
+ export { type FacingOptions, type FacingReport, type FeelOptions, type FeelReport, type FramingEntry, type FramingOptions, type FramingReport, GroundingIssue, GroundingOptions, type LadderVerdict, NodeCapture, type Outcome, PlaySession, PlaySessionOptions, type PlaytestOptions, type PlaytestReport, type PlaytestRun, RunContext, RunFailure, RunResult, RunScriptOptions, type RungResult, type RungStatus, SceneCapture, ScriptStep, ValidateSceneOptions, ValidationResult, type Where, auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, ladderText, ladderVerdict, playtest, playtestText, registerAllNodes, runScript, validateScene };
package/dist/test.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { r as auditScene } from "./replay-DilbZgQI.js";
2
- import { _ as framingText, a as registerAllNodes, c as feelReport, d as facingText, f as failingReplays, g as describeFraming, h as playtestText, i as findFloatingProps, l as feelText, m as playtest, n as createPlaySession, o as runScript, p as findPlayer, r as describeCapture, s as validateScene, t as captureScene, u as facingReport } from "./test-BboGNQr-.js";
3
- export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, playtest, playtestText, registerAllNodes, runScript, validateScene };
2
+ import { _ as playtestText, a as registerAllNodes, c as ladderText, d as feelText, f as facingReport, g as playtest, h as findPlayer, i as findFloatingProps, l as ladderVerdict, m as failingReplays, n as createPlaySession, o as runScript, p as facingText, r as describeCapture, s as validateScene, t as captureScene, u as feelReport, v as describeFraming, y as framingText } from "./test-E4-otKqK.js";
3
+ export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, ladderText, ladderVerdict, playtest, playtestText, registerAllNodes, runScript, validateScene };
package/dist/vite.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as FrameSignature } from "./frame-report-Ct8XgsmV.js";
1
+ import { n as FrameSignature } from "./frame-report-DZ70IY26.js";
2
2
 
3
3
  //#region src/vite/discover.d.ts
4
4
  /**
@@ -29,9 +29,20 @@ interface FrameHost {
29
29
  middlewares: {
30
30
  use(path: string, handler: (req: FrameReq, res: FrameRes) => void): void;
31
31
  };
32
- /** Vite's HMR channel. `ws` on v5, `hot` on v6+ — both are accepted. */
32
+ /**
33
+ * Vite's HMR channel, which has moved twice. `ws` on v5, `hot` on v6+, and
34
+ * since the Environment API the one that actually reaches the BROWSER is
35
+ * `environments.client.hot` — on v8 the top-level `hot` still exists and
36
+ * still accepts `send()` without error, it simply arrives nowhere. Every
37
+ * candidate is used, because a silent no-op is the worst of the three.
38
+ */
33
39
  ws?: FrameChannel;
34
40
  hot?: FrameChannel;
41
+ environments?: {
42
+ client?: {
43
+ hot?: FrameChannel;
44
+ };
45
+ };
35
46
  }
36
47
  interface FrameChannel {
37
48
  send(payload: {