incanto 0.46.0 → 0.47.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/dist/3d.js CHANGED
@@ -1,9 +1,96 @@
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-BtCNdkjI.js";
4
- import { a as frameStats, i as frameSignature, n as diffSignatures, o as frameText, r as diffText, t as SIGNATURE_GRID } from "./frame-report-Lr3VO24R.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";
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";
7
+ //#region src/3d/model-verdict.ts
8
+ /** Mixamo exports every bone as `mixamorigX`; the retargeter binds by that name. */
9
+ const MIXAMO = /^mixamorig[:_]?/i;
10
+ /**
11
+ * Anything outside this is not a character at this scale, and the fix is one
12
+ * prop. Wide on purpose: a 2.4m ogre is a design choice, a 40m one is
13
+ * centimetres.
14
+ */
15
+ const CHARACTER_HEIGHT = {
16
+ min: .4,
17
+ max: 4
18
+ };
19
+ /** What `targetHeight` gets set to when a model is plainly the wrong scale. */
20
+ const DEFAULT_TARGET_HEIGHT = 1.7;
21
+ /** Fraction of the model's own height that its origin may drift before it matters. */
22
+ const ORIGIN_TOLERANCE = .25;
23
+ function rigOf(facts) {
24
+ if (facts.vrm && facts.vrm.humanBones > 0) return "vrm-humanoid";
25
+ if (facts.nodes.some((n) => MIXAMO.test(n.name))) return "mixamorig";
26
+ return facts.skins > 0 ? "other" : "none";
27
+ }
28
+ function modelVerdict(facts, target) {
29
+ const rig = rigOf(facts);
30
+ const kind = facts.meshes === 0 && facts.animations.length > 0 ? "animation" : "model";
31
+ const height = facts.bbox ? facts.bbox.size[1] : 0;
32
+ const offset = facts.bbox ? [
33
+ facts.bbox.center[0],
34
+ facts.bbox.min[1],
35
+ facts.bbox.center[2]
36
+ ] : [
37
+ 0,
38
+ 0,
39
+ 0
40
+ ];
41
+ const slack = Math.max(height * ORIGIN_TOLERANCE, .05);
42
+ const grounded = !facts.bbox || Math.abs(offset[0]) <= slack && Math.abs(offset[1]) <= slack && Math.abs(offset[2]) <= slack;
43
+ const oddSize = height > 0 && (height < CHARACTER_HEIGHT.min || height > CHARACTER_HEIGHT.max);
44
+ const props = { model: `$${target?.key ?? "model"}` };
45
+ if (oddSize) props.targetHeight = DEFAULT_TARGET_HEIGHT;
46
+ return {
47
+ kind,
48
+ rig,
49
+ takesLibraryClips: kind === "model" && (rig === "mixamorig" || rig === "vrm-humanoid"),
50
+ height,
51
+ originOffset: offset,
52
+ groundedAtOrigin: grounded,
53
+ embeddedClips: facts.animations.map((a) => a.name),
54
+ sceneJson: target ? {
55
+ assets: { [target.key]: {
56
+ type: kind,
57
+ url: target.url
58
+ } },
59
+ node: kind === "animation" ? null : {
60
+ name: titleCase(target.key),
61
+ type: "ModelInstance3D",
62
+ props
63
+ }
64
+ } : null
65
+ };
66
+ }
67
+ const titleCase = (key) => key.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("") || "Model";
68
+ const round = (n) => String(Math.round(n * 100) / 100);
69
+ /** The verdict as sentences: what to do, and what will bite if you do not. */
70
+ function verdictText(v) {
71
+ const lines = [];
72
+ if (v.kind === "animation") {
73
+ lines.push("an animation CLIP — no mesh of its own. It plays ON a character: declare it as {\"type\":\"animation\"} and point a ModelInstance3D's `animation` at it. Used as a `model` it draws nothing at all, with no error");
74
+ if (v.embeddedClips.length > 0) lines.push(`clip: ${v.embeddedClips.join(", ")}`);
75
+ return lines.join("\n");
76
+ }
77
+ if (v.rig === "mixamorig") lines.push("mixamorig rig — the 3d/animations clips play on this as they are");
78
+ else if (v.rig === "vrm-humanoid") lines.push("VRM humanoid — clips are retargeted onto it automatically");
79
+ else if (v.rig === "other") lines.push("skinned, but NOT a mixamorig rig — the 3d/animations clips will not bind to it (bones are matched by name; a mismatch keeps the bind pose and only warns)");
80
+ else lines.push("no skeleton — a prop, not a character; `animation` does nothing here");
81
+ if (v.height > 0) {
82
+ const odd = v.height < CHARACTER_HEIGHT.min || v.height > CHARACTER_HEIGHT.max;
83
+ lines.push(`stands ${round(v.height)} units tall` + (odd ? ` — not a character's size; set targetHeight (${DEFAULT_TARGET_HEIGHT}) to fix it` : ""));
84
+ }
85
+ if (!v.groundedAtOrigin) {
86
+ const [x, y, z] = v.originOffset;
87
+ const where = Math.abs(y) > Math.abs(x) && Math.abs(y) > Math.abs(z) ? `${round(Math.abs(y))} units ${y > 0 ? "above" : "below"} the origin` : `off-centre by [${round(x)}, ${round(y)}, ${round(z)}]`;
88
+ lines.push(`does NOT stand on its own origin — it sits ${where}. A node at [0,0,0] puts it there too; offset the node, or expect an empty-looking frame`);
89
+ }
90
+ if (v.embeddedClips.length > 0) lines.push(`clips in the file: ${v.embeddedClips.join(", ")} (play by name, no asset needed)`);
91
+ return lines.join("\n");
92
+ }
93
+ //#endregion
7
94
  //#region src/3d/terrain-nav.ts
8
95
  function buildTerrainNav(terrain, opts) {
9
96
  const cellSize = opts?.cellSize ?? 2;
@@ -115,4 +202,4 @@ function createNavDebugNode(nav, opts) {
115
202
  return node;
116
203
  }
117
204
  //#endregion
118
- export { Area3D, AssetStore3D, Billboard3D, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, Environment3D, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, Flowers3D, Foliage3D, InstancedMesh3D, Joint3D, LoftMesh3D, MeshInstance3D, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, PhysicsBody3D, QUARTER_PITCH, Renderer3D, RigidBody3D, River3D, SIGNATURE_GRID, StaticBody3D, TERRAIN_THEMES, Terrain3D, TextureCache3D, Trail3D, Tree3D, VOXEL_PALETTE, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
205
+ export { Area3D, AssetStore3D, Billboard3D, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, Environment3D, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, Flowers3D, Foliage3D, InstancedMesh3D, Joint3D, LoftMesh3D, MeshInstance3D, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, PhysicsBody3D, QUARTER_PITCH, Renderer3D, RigidBody3D, River3D, SIGNATURE_GRID, StaticBody3D, TERRAIN_THEMES, Terrain3D, TextureCache3D, Trail3D, Tree3D, VOXEL_PALETTE, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, modelVerdict, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath, verdictText };
@@ -7,7 +7,7 @@ import { a as openBundledEditor, i as devServerLibrary, n as pauseWhenHidden, o
7
7
  import { B as createCausticsQuad, F as PhysicsBody3D, R as Node3D, n as registerGameplayBehaviors } from "./gameplay-BQOeAid6.js";
8
8
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
9
9
  import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-D1b0ydTS.js";
10
- import { a as frameStats, i as frameSignature } from "./frame-report-Lr3VO24R.js";
10
+ import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-njybhZon.js";
11
11
  import { n as enablePhysics3D } from "./physics-3d-CSoGjM8P.js";
12
12
  import { ACESFilmicToneMapping, AmbientLight, Box3, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, HalfFloatType, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
13
13
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
@@ -1545,6 +1545,12 @@ function prune(obj, visited) {
1545
1545
  //#endregion
1546
1546
  //#region src/3d/renderer.ts
1547
1547
  /**
1548
+ * How long a capture waits for the game's own loop before drawing its own
1549
+ * frame. Long enough that a running game answers from its normal loop (16ms),
1550
+ * short enough that a paused or occluded tab still answers promptly.
1551
+ */
1552
+ const CAPTURE_NUDGE_MS = 120;
1553
+ /**
1548
1554
  * WebGL presentation layer: subscribes to `engine.updated`, mirrors the active
1549
1555
  * scene's node tree onto a three.js scene, applies the scene `environment`
1550
1556
  * header (ambient/background/sky/fog/shadows/exposure — see Environment3D),
@@ -1890,6 +1896,14 @@ var Renderer3D = class {
1890
1896
  resolve,
1891
1897
  reject
1892
1898
  });
1899
+ setTimeout(() => {
1900
+ if (this.pendingCaptures.length > 0) try {
1901
+ this.render();
1902
+ } catch (error) {
1903
+ const e = error instanceof Error ? error : new Error(String(error));
1904
+ for (const w of this.pendingCaptures.splice(0)) w.reject(e);
1905
+ }
1906
+ }, CAPTURE_NUDGE_MS);
1893
1907
  });
1894
1908
  }
1895
1909
  pendingCaptures = [];
@@ -2446,11 +2460,16 @@ async function createGame3D(opts) {
2446
2460
  const onFrameRequest = (data) => {
2447
2461
  const request = data;
2448
2462
  const id = request?.id;
2463
+ hot.send("incanto:frame-ack", { id });
2449
2464
  captureFramePixels().then((shot) => hot.send("incanto:frame-report", {
2450
2465
  id,
2451
2466
  report: {
2452
2467
  ...frameStats(shot.pixels, shot.width, shot.height, { grid: request?.grid }),
2453
- signature: frameSignature(shot.pixels, shot.width, shot.height)
2468
+ signature: frameSignature(shot.pixels, shot.width, shot.height),
2469
+ ...request?.image ? { image: frameImage(shot.pixels, shot.width, shot.height, (w, h) => Object.assign(document.createElement("canvas"), {
2470
+ width: w,
2471
+ height: h
2472
+ }), request.image) } : {}
2454
2473
  }
2455
2474
  })).catch((error) => hot.send("incanto:frame-report", {
2456
2475
  id,
@@ -28,6 +28,31 @@ interface FrameStats {
28
28
  meanLuma: number;
29
29
  /** Coarse mean colour per cell, rows top to bottom. */
30
30
  grid: GridCell[][];
31
+ /** What the frame is OF, or null when it is all background. */
32
+ subject: Subject | null;
33
+ }
34
+ /**
35
+ * The thing in the shot: everything that is not the background.
36
+ *
37
+ * `black` and `uniform` answer "did anything render at all". They do not answer
38
+ * the question that actually goes wrong — "is the thing IN the shot, and can
39
+ * you see it" — and every visual defect reported against the editor's model
40
+ * previews walked straight past them. A white square with a faint sky gradient
41
+ * is neither black nor uniform; a character drawn as a speck in the corner has
42
+ * a perfectly ordinary mean luminance.
43
+ */
44
+ interface Subject {
45
+ /** Bounding box in pixels, origin top-left. */
46
+ box: {
47
+ x: number;
48
+ y: number;
49
+ w: number;
50
+ h: number;
51
+ };
52
+ /** Share of the frame's pixels that are subject, 0..1. */
53
+ coverage: number;
54
+ /** Touching an edge — the shot is a crop, and something is outside it. */
55
+ clipped: boolean;
31
56
  }
32
57
  interface FrameStatsOptions {
33
58
  /** Cells across and down. Default 16x9 — 144 cells, printable. */
@@ -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.js CHANGED
@@ -7,7 +7,7 @@ import { n as jsonEquals, r as jsonKind, t as jsonClone } from "./json-BLk7H2Qa.
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
8
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
9
9
  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";
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
11
  import { i as resolveRendering, n as attachTouchControls, r as joystickVector, t as TouchControls } from "./touch-BoNg_MnF.js";
12
12
  import { t as duplicateNode } from "./duplicate-CRtihGmC.js";
13
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 };
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-D16MVIPO.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.47.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: {