incanto 0.69.0 → 0.71.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/bin/_behaviors-loader.mjs +22 -0
- package/bin/_read-json.mjs +28 -0
- package/bin/incanto-assets.mjs +5 -4
- package/bin/incanto-check.mjs +58 -31
- package/bin/incanto-editor.mjs +128 -5
- package/bin/incanto-env.mjs +3 -2
- package/bin/incanto-feel.mjs +24 -13
- package/bin/incanto-frame.mjs +8 -1
- package/bin/incanto-multiplay.mjs +11 -9
- package/bin/incanto-new.mjs +105 -10
- package/bin/incanto-play.mjs +158 -16
- package/bin/incanto-playtest.mjs +66 -23
- package/bin/incanto-skills.mjs +14 -2
- package/bin/incanto-verify.mjs +121 -102
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +296 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +8 -8
- package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
- package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -3
- package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
- package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
- package/dist/editor.js +1088 -882
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
- package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
- package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
- package/dist/gameplay.d.ts +254 -10
- package/dist/gameplay.js +2 -2
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +179 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
- package/dist/net.d.ts +27 -3
- package/dist/net.js +2 -2
- package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
- package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
- package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
- package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
- package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
- package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
- package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
- package/dist/test-CmN54kWv.js +4264 -0
- package/dist/test.d.ts +639 -30
- package/dist/test.js +3 -3
- package/dist/touch-BnCyPA0G.js +519 -0
- package/dist/vite.d.ts +20 -37
- package/dist/vite.js +177 -7
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-yLIEYHbd.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
- package/editor/assets/index-DK9xMGpW.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +34 -3
- package/skills/incanto-3d-character.md +14 -1
- package/skills/incanto-3d-models.md +12 -0
- package/skills/incanto-assets.md +9 -1
- package/skills/incanto-audio.md +8 -2
- package/skills/incanto-behaviors-and-scripts.md +105 -7
- package/skills/incanto-building-2d-games.md +97 -10
- package/skills/incanto-building-3d-games.md +58 -4
- package/skills/incanto-editor.md +46 -7
- package/skills/incanto-environment.md +19 -1
- package/skills/incanto-game-feel.md +70 -0
- package/skills/incanto-gameplay-behaviors.md +172 -20
- package/skills/incanto-hud.md +142 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +239 -59
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +112 -2
- package/skills/incanto-save-slots.md +217 -7
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +207 -10
- package/skills/incanto-web-integration.md +28 -0
- package/skills/incanto-your-first-game.md +3 -1
- package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
- package/templates-app/beacon-isle-3d/package.json +2 -2
- package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
- package/templates-app/beacon-isle-3d/src/main.ts +11 -1
- package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
- package/templates-app/beacon-isle-3d/verify.ts +3 -1
- package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
- package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
- package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
- package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
- package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
- package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
- package/templates-app/molehill-2d/index.html +73 -0
- package/templates-app/molehill-2d/package.json +23 -0
- package/templates-app/molehill-2d/src/behaviors.ts +198 -0
- package/templates-app/molehill-2d/src/game.scene.json +1205 -0
- package/templates-app/molehill-2d/src/main.ts +41 -0
- package/templates-app/molehill-2d/tsconfig.json +13 -0
- package/templates-app/molehill-2d/verify.ts +247 -0
- package/templates-app/molehill-2d/vite.config.ts +12 -0
- package/templates-app/platformer-2d/package.json +2 -2
- package/templates-app/platformer-2d/src/game.scene.json +178 -986
- package/templates-app/platformer-2d/src/main.ts +11 -1
- package/templates-app/platformer-2d/tsconfig.json +1 -1
- package/templates-app/star-survivor/package.json +2 -2
- package/templates-app/star-survivor/src/game.scene.json +43 -216
- package/templates-app/star-survivor/src/main.ts +11 -1
- package/templates-app/star-survivor/tsconfig.json +1 -1
- package/templates-app/tps-3d/PROJECT/Context.md +1 -1
- package/templates-app/tps-3d/package.json +2 -2
- package/templates-app/tps-3d/src/behaviors.ts +19 -1
- package/templates-app/tps-3d/src/game.scene.json +66 -263
- package/templates-app/tps-3d/src/main.ts +13 -2
- package/templates-app/tps-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
- package/templates-app/village-quest-3d/package.json +2 -2
- package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
- package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +202 -863
- package/templates-app/village-quest-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/verify.ts +14 -1
- package/dist/duplicate-Dj4L-5AA.js +0 -22
- package/dist/json-CwwhxQgb.js +0 -36
- package/dist/registry-CF70EArN.js +0 -212
- package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
- package/dist/test-D6ISmuFE.js +0 -2383
- package/dist/touch-DEAmqGdf.js +0 -225
- package/editor/assets/agent8-BDvaZMQJ.js +0 -1
- package/editor/assets/index-CZIRr5EL.js +0 -10958
package/bin/incanto-playtest.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync,
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
/**
|
|
5
5
|
* incanto-playtest — the engine plays your game and tells you whether it works.
|
|
6
6
|
*
|
|
@@ -23,26 +23,50 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
23
23
|
* Exit code 1 when no run reached a win, so CI can gate on it.
|
|
24
24
|
*/
|
|
25
25
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
-
import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
|
|
26
|
+
import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
|
|
27
|
+
import { readJson } from './_read-json.mjs';
|
|
27
28
|
|
|
28
29
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const PKG_VERSION = readJson(join(PKG, 'package.json')).version;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A numeric flag with a missing or non-numeric value is a TYPO.
|
|
34
|
+
*
|
|
35
|
+
* `--runs $RUNS` with RUNS unset swallowed the NEXT flag as its value and gave
|
|
36
|
+
* `Number(undefined)` = NaN, which ran ZERO runs and then printed a confident
|
|
37
|
+
* report asserting the opposite of the truth about the scene — exit 0.
|
|
38
|
+
*/
|
|
39
|
+
function num(raw, flag) {
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
if (raw === undefined || raw === '' || !Number.isFinite(n)) {
|
|
42
|
+
console.error(`${flag} needs a number, got ${raw === undefined ? 'nothing' : `'${raw}'`}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
29
47
|
|
|
30
48
|
function parseArgs(argv) {
|
|
31
49
|
const args = { runs: 20, seconds: 60, seed: 1, out: '.incanto/playtest' };
|
|
32
50
|
for (let i = 0; i < argv.length; i++) {
|
|
33
51
|
const a = argv[i];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else if (a === '--
|
|
37
|
-
else if (a === '--
|
|
38
|
-
else if (a === '--
|
|
39
|
-
else if (a === '--
|
|
52
|
+
// Repeatable: behaviours legitimately live in more than one file.
|
|
53
|
+
if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
|
|
54
|
+
else if (a === '--runs') args.runs = num(argv[++i], '--runs');
|
|
55
|
+
else if (a === '--seconds') args.seconds = num(argv[++i], '--seconds');
|
|
56
|
+
else if (a === '--seed') args.seed = num(argv[++i], '--seed');
|
|
57
|
+
else if (a === '--fall-below') args.fallBelow = num(argv[++i], '--fall-below');
|
|
58
|
+
else if (a === '--reach-radius') args.reachRadius = num(argv[++i], '--reach-radius');
|
|
40
59
|
else if (a === '--out') args.out = argv[++i];
|
|
41
60
|
else if (a === '--no-replays') args.noReplays = true;
|
|
42
61
|
else if (a === '--json') args.json = true;
|
|
43
62
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
44
63
|
else if (a.startsWith('--')) {
|
|
64
|
+
// `invalid`, not `help`: printing usage and exiting 0 told an agent its
|
|
65
|
+
// typo was a request. `--runs=4`, `--behaviours`, `--no-replay` all landed
|
|
66
|
+
// here and looked like success — and with `--json` the machine path was
|
|
67
|
+
// literally silent (0 bytes on stdout).
|
|
45
68
|
console.error(`unknown flag: ${a}`);
|
|
69
|
+
args.invalid = true;
|
|
46
70
|
args.help = true;
|
|
47
71
|
} else if (!args.scene) args.scene = a;
|
|
48
72
|
}
|
|
@@ -60,24 +84,28 @@ if (args.help || !args.scene) {
|
|
|
60
84
|
3D, 1000 px under it in 2D — 2D counts DOWN the screen)
|
|
61
85
|
--reach-radius R how close counts as "reached" (default 2 in 3D, 32 px in 2D)
|
|
62
86
|
--behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
|
|
87
|
+
comma-separate or repeat for several files
|
|
63
88
|
--out DIR where failing replays go (default .incanto/playtest)
|
|
64
89
|
--no-replays report only, write nothing
|
|
65
90
|
--json the full report as JSON instead of prose
|
|
66
91
|
|
|
67
|
-
Exits 1 when
|
|
68
|
-
|
|
92
|
+
Exits 1 when a run THREW or left the world, or when a win is declared and no
|
|
93
|
+
run reached it. A scene that declares no win, or has nobody to drive, exits 0 —
|
|
94
|
+
the report marks those · rather than ✗, and the exit code says the same.`);
|
|
95
|
+
process.exit(!args.invalid && args.help && args.scene !== undefined ? 0 : 1);
|
|
69
96
|
}
|
|
70
97
|
|
|
71
|
-
const { playtest, playtestText, failingReplays } = await import(
|
|
98
|
+
const { playtest, playtestText, playtestFailed, failingReplays } = await import(
|
|
72
99
|
pathToFileURL(join(PKG, 'dist', 'test.js')).href
|
|
73
100
|
);
|
|
74
101
|
const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
|
|
75
102
|
|
|
76
103
|
const behaviors = {};
|
|
77
|
-
|
|
104
|
+
const behaviorSources = behaviorFiles(args.behaviors);
|
|
105
|
+
for (const file of behaviorSources) {
|
|
78
106
|
let mod;
|
|
79
107
|
try {
|
|
80
|
-
mod = await import(pathToFileURL(resolve(
|
|
108
|
+
mod = await import(pathToFileURL(resolve(file)).href);
|
|
81
109
|
} catch (e) {
|
|
82
110
|
const why = e?.message ?? String(e);
|
|
83
111
|
// NODE CANNOT IMPORT THE PATTERN WE TEACH. Every template's behaviors.ts
|
|
@@ -94,7 +122,7 @@ if (args.behaviors) {
|
|
|
94
122
|
// `village-quest-3d` starter, and it breaks for any author who splits their
|
|
95
123
|
// behaviours across files — which is most of them, eventually.
|
|
96
124
|
reexecUnderBun(import.meta.url, why);
|
|
97
|
-
console.error(behaviorsHelp(
|
|
125
|
+
console.error(behaviorsHelp(file, why));
|
|
98
126
|
process.exit(1);
|
|
99
127
|
}
|
|
100
128
|
for (const [name, value] of Object.entries(mod)) {
|
|
@@ -102,13 +130,13 @@ if (args.behaviors) {
|
|
|
102
130
|
behaviors[name] = value;
|
|
103
131
|
}
|
|
104
132
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
133
|
+
}
|
|
134
|
+
if (behaviorSources.length > 0 && Object.keys(behaviors).length === 0) {
|
|
135
|
+
console.error(`warning: no Behavior subclasses exported from ${behaviorSources.join(', ')}`);
|
|
108
136
|
}
|
|
109
137
|
|
|
110
138
|
const scenePath = resolve(args.scene);
|
|
111
|
-
const sceneJson =
|
|
139
|
+
const sceneJson = readJson(scenePath);
|
|
112
140
|
const sceneDir = dirname(scenePath);
|
|
113
141
|
|
|
114
142
|
let report;
|
|
@@ -118,11 +146,11 @@ try {
|
|
|
118
146
|
seconds: args.seconds,
|
|
119
147
|
seed: args.seed,
|
|
120
148
|
behaviors,
|
|
121
|
-
stubMissingBehaviors:
|
|
149
|
+
stubMissingBehaviors: behaviorSources.length === 0,
|
|
122
150
|
...(Number.isFinite(args.fallBelow) ? { fallBelow: args.fallBelow } : {}),
|
|
123
151
|
...(Number.isFinite(args.reachRadius) ? { reachRadius: args.reachRadius } : {}),
|
|
124
152
|
// sub-scenes resolve relative to the scene file, as the loader does
|
|
125
|
-
resolveScene: (p) =>
|
|
153
|
+
resolveScene: (p) => readJson(resolve(sceneDir, p)),
|
|
126
154
|
});
|
|
127
155
|
} catch (e) {
|
|
128
156
|
console.error(`playtest failed: ${e?.message ?? e}`);
|
|
@@ -140,12 +168,27 @@ if (args.json) {
|
|
|
140
168
|
if (!args.noReplays && !args.json) {
|
|
141
169
|
const seen = new Set();
|
|
142
170
|
const written = [];
|
|
171
|
+
/*
|
|
172
|
+
* Stamp WHICH GAME each recording is of.
|
|
173
|
+
*
|
|
174
|
+
* `.incanto/playtest` is keyed by `(outcome, seed)` and nothing else, and
|
|
175
|
+
* never cleaned. So the second playtest in a project either leaves a stale
|
|
176
|
+
* `stuck-seed3.json` from a scene that no longer exists sitting beside the
|
|
177
|
+
* current one, or silently overwrites another scene's — and
|
|
178
|
+
* `incanto-play --commands` happily replays any recording against any scene
|
|
179
|
+
* and reports success, because nothing in the file said what it was of.
|
|
180
|
+
*/
|
|
181
|
+
const stamp = {
|
|
182
|
+
path: relative(process.cwd(), scenePath) || scenePath,
|
|
183
|
+
name: typeof sceneJson?.name === 'string' ? sceneJson.name : undefined,
|
|
184
|
+
engine: PKG_VERSION,
|
|
185
|
+
};
|
|
143
186
|
for (const { seed, outcome, replay } of failingReplays(report)) {
|
|
144
187
|
if (seen.has(outcome)) continue;
|
|
145
188
|
seen.add(outcome);
|
|
146
189
|
const file = join(resolve(args.out), `${outcome}-seed${seed}.json`);
|
|
147
190
|
mkdirSync(dirname(file), { recursive: true });
|
|
148
|
-
writeFileSync(file, JSON.stringify(replay, null, 2));
|
|
191
|
+
writeFileSync(file, JSON.stringify({ ...replay, scene: stamp }, null, 2));
|
|
149
192
|
written.push(file);
|
|
150
193
|
}
|
|
151
194
|
if (written.length > 0) {
|
|
@@ -157,4 +200,4 @@ if (!args.noReplays && !args.json) {
|
|
|
157
200
|
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
158
201
|
// report read by another program came back truncated — silently, and only
|
|
159
202
|
// when piped, which is the only way a program reads it.
|
|
160
|
-
process.exitCode = report
|
|
203
|
+
process.exitCode = playtestFailed(report) ? 1 : 0;
|
package/bin/incanto-skills.mjs
CHANGED
|
@@ -22,8 +22,19 @@ function parseArgs(argv) {
|
|
|
22
22
|
if (argv[i] === '--format') args.format = argv[++i];
|
|
23
23
|
else if (argv[i] === '--out') args.out = argv[++i];
|
|
24
24
|
else if (argv[i] === '--help' || argv[i] === '-h') args.help = true;
|
|
25
|
+
/*
|
|
26
|
+
* The bare word, because that is what everything tells you to type.
|
|
27
|
+
*
|
|
28
|
+
* `incanto-new`'s closing message — the last thing a scaffolded project
|
|
29
|
+
* prints, and the line that installs the skills the whole workflow depends
|
|
30
|
+
* on — said `bunx incanto-skills claude`, and that was an unknown argument.
|
|
31
|
+
* The flag form stays; this makes the printed line true.
|
|
32
|
+
*/ else if (argv[i] === 'claude' || argv[i] === 'opencode') args.format = argv[i];
|
|
25
33
|
else {
|
|
34
|
+
// Not `help`: printing usage and exiting 0 for a typo meant
|
|
35
|
+
// `incanto-skills --oops` installed ZERO skills and reported success.
|
|
26
36
|
console.error(`unknown argument: ${argv[i]}`);
|
|
37
|
+
args.invalid = true;
|
|
27
38
|
args.help = true;
|
|
28
39
|
}
|
|
29
40
|
}
|
|
@@ -32,7 +43,8 @@ function parseArgs(argv) {
|
|
|
32
43
|
|
|
33
44
|
const args = parseArgs(process.argv.slice(2));
|
|
34
45
|
if (args.help || !['claude', 'opencode'].includes(args.format)) {
|
|
35
|
-
console.log(`Usage: npx incanto-skills [
|
|
46
|
+
console.log(`Usage: npx incanto-skills [claude|opencode] [--out <dir>]
|
|
47
|
+
npx incanto-skills [--format claude|opencode] [--out <dir>]
|
|
36
48
|
|
|
37
49
|
claude (default) Claude Code project skills:
|
|
38
50
|
<out>/<skill-name>/SKILL.md (default out: .claude/skills)
|
|
@@ -40,7 +52,7 @@ if (args.help || !['claude', 'opencode'].includes(args.format)) {
|
|
|
40
52
|
<out>/<skill-name>.md (default out: skills)
|
|
41
53
|
|
|
42
54
|
Skills are read from the installed incanto package and match its version.`);
|
|
43
|
-
process.exit(args.help ? 0 : 1);
|
|
55
|
+
process.exit(!args.invalid && args.help ? 0 : 1);
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
const outRoot = resolve(args.out ?? (args.format === 'claude' ? '.claude/skills' : 'skills'));
|
package/bin/incanto-verify.mjs
CHANGED
|
@@ -19,23 +19,20 @@
|
|
|
19
19
|
* what to arrange, and says so in the summary.
|
|
20
20
|
*/
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, readdirSync,
|
|
22
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
23
23
|
import { dirname, join, relative } from 'node:path';
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
import { readJson } from './_read-json.mjs';
|
|
25
26
|
|
|
26
27
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
-
const { ladderText, ladderVerdict } = await import(
|
|
28
|
+
const { feelsRung, firstLine, ladderText, ladderVerdict, saysRung, unmeasuredPlays } = await import(
|
|
28
29
|
pathToFileURL(join(PKG, 'dist', 'test.js')).href
|
|
29
30
|
);
|
|
30
31
|
|
|
31
32
|
const argv = process.argv.slice(2);
|
|
32
33
|
const asJson = argv.includes('--json');
|
|
33
|
-
const flag = (name) => {
|
|
34
|
-
const i = argv.indexOf(name);
|
|
35
|
-
return i >= 0 ? argv[i + 1] : null;
|
|
36
|
-
};
|
|
37
34
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
38
|
-
console.log(`Usage: incanto verify [scene.json] [--behaviors FILE] [--json]
|
|
35
|
+
console.log(`Usage: incanto verify [scene.json] [--behaviors FILE[,FILE...]] [--json]
|
|
39
36
|
|
|
40
37
|
Walks the whole ladder and tells you the ONE thing to do next:
|
|
41
38
|
|
|
@@ -111,7 +108,26 @@ const { scene, candidates } = findScene();
|
|
|
111
108
|
if (!scene) {
|
|
112
109
|
const report = ladderVerdict([], { candidates });
|
|
113
110
|
console.log(asJson ? JSON.stringify(report, null, 2) : ladderText(report));
|
|
114
|
-
|
|
111
|
+
/*
|
|
112
|
+
* Exit 2, not 0.
|
|
113
|
+
*
|
|
114
|
+
* "Unmeasured is not a failure" is the rule for a RUNG — the game is fine and
|
|
115
|
+
* a question could not be asked. This is different: nothing was measured at
|
|
116
|
+
* ALL, and a CI step whose whole job is `incanto-verify` went green having
|
|
117
|
+
* looked at nothing:
|
|
118
|
+
*
|
|
119
|
+
* 2 scenes here, and guessing between them would verify the wrong one:
|
|
120
|
+
* src/game.scene.json
|
|
121
|
+
* src/title.scene.json
|
|
122
|
+
* $ echo $?
|
|
123
|
+
* 0
|
|
124
|
+
*
|
|
125
|
+
* A distinct code rather than 1, so a script can still tell "your game is
|
|
126
|
+
* broken" from "tell me WHICH game" — and `verdict.measured` says the same
|
|
127
|
+
* thing to anything reading `--json`. Multi-scene is the shape of almost
|
|
128
|
+
* every real game, so this is not an edge case for long.
|
|
129
|
+
*/
|
|
130
|
+
process.exit(2);
|
|
115
131
|
}
|
|
116
132
|
if (!existsSync(scene) || !statSync(scene).isFile()) {
|
|
117
133
|
console.error(`no such scene: ${scene}`);
|
|
@@ -129,8 +145,20 @@ if (!existsSync(scene) || !statSync(scene).isFile()) {
|
|
|
129
145
|
* path to a file sitting right next to the scene it had already found.
|
|
130
146
|
*/
|
|
131
147
|
function findBehaviors(sceneFile) {
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
/*
|
|
149
|
+
* Every `--behaviors`, not the first one.
|
|
150
|
+
*
|
|
151
|
+
* The flag is repeatable in the bins this spawns, and reading only
|
|
152
|
+
* `argv.indexOf` would have silently dropped every file after the first —
|
|
153
|
+
* the CLI accepting an argument and then not using it, which is the shape
|
|
154
|
+
* this round keeps finding. Joined with commas, which is the other form the
|
|
155
|
+
* children take.
|
|
156
|
+
*/
|
|
157
|
+
const named = [];
|
|
158
|
+
for (let i = 0; i < argv.length; i++) {
|
|
159
|
+
if (argv[i] === '--behaviors' && argv[i + 1]) named.push(argv[++i]);
|
|
160
|
+
}
|
|
161
|
+
if (named.length > 0) return { file: named.join(','), guessed: false };
|
|
134
162
|
const near = dirname(sceneFile);
|
|
135
163
|
for (const dir of [near, join(near, '..'), process.cwd(), join(process.cwd(), 'src')]) {
|
|
136
164
|
for (const base of ['behaviors', 'behaviours']) {
|
|
@@ -190,6 +218,9 @@ const rungs = [];
|
|
|
190
218
|
/** The playtest's JSON, shared by the `plays` and `feels` rungs. */
|
|
191
219
|
let playtestReport = null;
|
|
192
220
|
|
|
221
|
+
/** Did the bot get the game MOVING? Set by the `plays` rung, read by `feels`. */
|
|
222
|
+
let botPlayed = true;
|
|
223
|
+
|
|
193
224
|
// ---- plays ---------------------------------------------------------------
|
|
194
225
|
if (rungs[0].status === 'pass') {
|
|
195
226
|
const args = [scene, '--json', '--runs', '8'];
|
|
@@ -246,6 +277,8 @@ if (rungs[0].status === 'pass') {
|
|
|
246
277
|
const DEFECT = new Set(stuckCounts ? ['error', 'fell', 'stuck'] : ['error', 'fell']);
|
|
247
278
|
const defects = out?.runs?.filter((x) => DEFECT.has(x.outcome)) ?? [];
|
|
248
279
|
const undrivable = total > 0 && defects.length === 0 && tally('stuck') === total;
|
|
280
|
+
// …and the `feels` rung below asks the same question, so it is answered once.
|
|
281
|
+
botPlayed = !undrivable;
|
|
249
282
|
const count = (n, word) => n && `${n} ${word}`;
|
|
250
283
|
|
|
251
284
|
if (total === 0) {
|
|
@@ -275,32 +308,50 @@ if (rungs[0].status === 'pass') {
|
|
|
275
308
|
.join(', ') +
|
|
276
309
|
')' +
|
|
277
310
|
(won > 0 ? ` — ${won} did finish it` : ''),
|
|
311
|
+
// The throw itself, not a command to go and find it. This rung used to
|
|
312
|
+
// say "read the throw: incanto-playtest …" — sending its reader to
|
|
313
|
+
// another tool for a line this JSON already carries.
|
|
314
|
+
notes: (out?.errorMessages ?? []).slice(0, 3),
|
|
278
315
|
fix:
|
|
279
316
|
tally('error') > 0
|
|
280
|
-
? `
|
|
317
|
+
? `fix the script named above, then re-run \`incanto-verify ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``
|
|
281
318
|
: tally('fell') > 0
|
|
282
319
|
? `the player left the world with nothing catching them — give the level a floor, walls, or a respawn (\`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\` prints where)`
|
|
283
320
|
: `nothing moved — check the input map and the controller: \`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``,
|
|
284
321
|
});
|
|
285
|
-
} else if (
|
|
322
|
+
} else if (won > 0) {
|
|
323
|
+
/*
|
|
324
|
+
* `r.status === 0` used to stand in for "it finished the game", and the
|
|
325
|
+
* exit code stopped meaning that: `incanto-playtest` now exits 0 for a
|
|
326
|
+
* scene with nobody to drive, because never being asked is not a failure.
|
|
327
|
+
* This rung read that 0 and reported
|
|
328
|
+
*
|
|
329
|
+
* ✓ plays — 0 of 8 seeded runs finished it
|
|
330
|
+
*
|
|
331
|
+
* on a scene with no character in it, right where it had been careful to
|
|
332
|
+
* say `? plays — nothing here is drivable`. The report has `runs`; ask it
|
|
333
|
+
* what happened rather than asking the child how it felt about it.
|
|
334
|
+
*/
|
|
286
335
|
rungs.push({
|
|
287
336
|
name: 'plays',
|
|
288
337
|
status: 'pass',
|
|
289
338
|
summary: `${won} of ${total} seeded runs finished it`,
|
|
290
339
|
});
|
|
291
340
|
} else if (undrivable) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
341
|
+
/*
|
|
342
|
+
* "Nobody to move" was said about games the bot had PLAYED.
|
|
343
|
+
*
|
|
344
|
+
* The driver has driven the mouse for a while, and this rung only ever
|
|
345
|
+
* asked whether a character could walk — so a flip puzzle whose every tile
|
|
346
|
+
* the bot clicked, whose `clicked` wires all fired and whose board changed
|
|
347
|
+
* state, was told to add a character controller. A whole class of games —
|
|
348
|
+
* match-3, tower defense, cards, point-and-click, RTS, board games — has no
|
|
349
|
+
* character to add, and the advice was to add one anyway.
|
|
350
|
+
*/
|
|
351
|
+
// The wording lives in `verify-ladder.ts` (pure, and tested there) —
|
|
352
|
+
// this branch owns only the fact that the bot could not WALK.
|
|
353
|
+
const said = unmeasuredPlays({ ...(out ?? {}), scene });
|
|
354
|
+
rungs.push({ name: 'plays', status: 'unmeasured', summary: said.summary, fix: said.fix });
|
|
304
355
|
} else if (noGoal) {
|
|
305
356
|
rungs.push({
|
|
306
357
|
name: 'plays',
|
|
@@ -317,9 +368,22 @@ if (rungs[0].status === 'pass') {
|
|
|
317
368
|
rungs.push({
|
|
318
369
|
name: 'plays',
|
|
319
370
|
status: 'unmeasured',
|
|
371
|
+
// The parenthetical is only worth printing when it has something in it.
|
|
372
|
+
// It listed `lost` and `unfinished` and nothing else, so a game whose
|
|
373
|
+
// runs all ended at a SCENE SWAP — every tally zero — reported
|
|
374
|
+
// `8 runs played without reaching a win ()`, an empty bracket where the
|
|
375
|
+
// reason should be. `swapped` IS the reason, and it is the one outcome
|
|
376
|
+
// here that explains itself.
|
|
320
377
|
summary:
|
|
321
378
|
`${total} runs played without reaching a win` +
|
|
322
|
-
|
|
379
|
+
(() => {
|
|
380
|
+
const why = [
|
|
381
|
+
count(tally('lost'), 'lost'),
|
|
382
|
+
count(tally('unfinished'), 'ran out the clock'),
|
|
383
|
+
count(tally('swapped'), 'changed scene, where this bot stops measuring'),
|
|
384
|
+
].filter(Boolean);
|
|
385
|
+
return why.length > 0 ? ` (${why.join(', ')})` : '';
|
|
386
|
+
})(),
|
|
323
387
|
fix: 'nothing here is broken — a win that takes skill or a sequence is out of reach of random play. Judge it with a scripted run: `bun run verify`, or `runScript` from `incanto/test`',
|
|
324
388
|
});
|
|
325
389
|
}
|
|
@@ -331,48 +395,10 @@ if (rungs[0].status === 'pass') {
|
|
|
331
395
|
// Sound and effects, from the same playtest that just ran. A game whose
|
|
332
396
|
// feedback is wired but never triggered plays perfectly and feels dead, and
|
|
333
397
|
// every other rung here calls it healthy.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (!fb) {
|
|
339
|
-
rungs.push({
|
|
340
|
-
name: 'feels',
|
|
341
|
-
status: 'skipped',
|
|
342
|
-
summary: 'not run — the playtest did not report',
|
|
343
|
-
});
|
|
344
|
-
} else if (declared === 0) {
|
|
345
|
-
rungs.push({
|
|
346
|
-
name: 'feels',
|
|
347
|
-
status: 'skipped',
|
|
348
|
-
summary: 'this game declares no sound and no effects',
|
|
349
|
-
});
|
|
350
|
-
} else if (fired.size === 0) {
|
|
351
|
-
rungs.push({
|
|
352
|
-
name: 'feels',
|
|
353
|
-
status: 'fail',
|
|
354
|
-
summary: `${declared} sound/effect node(s) declared, and not one fired in any run`,
|
|
355
|
-
fix: 'connect them — a signal to `play` on an AudioPlayer, or `replay()` on a one-shot; `engine.audio.recent()` and `engine.effects.recent()` say what did fire',
|
|
356
|
-
});
|
|
357
|
-
} else {
|
|
358
|
-
const declaredPaths = [...(fb.declaredAudio ?? []), ...(fb.declaredEffects ?? [])];
|
|
359
|
-
const silent = declaredPaths.filter((path) => !fired.has(path));
|
|
360
|
-
// Of the nodes this rung is ABOUT, how many fired. `fired` also holds paths
|
|
361
|
-
// nobody declared — a spawned clone sounds from its own path, a procedural
|
|
362
|
-
// preset from wherever a behaviour played it — so using its size printed
|
|
363
|
-
// arithmetic that cannot be true: `✓ feels — 12 of 9 fired`. The number has
|
|
364
|
-
// to add up with the `silent` list beside it.
|
|
365
|
-
const firedDeclared = declaredPaths.length - silent.length;
|
|
366
|
-
rungs.push({
|
|
367
|
-
name: 'feels',
|
|
368
|
-
status: 'pass',
|
|
369
|
-
summary:
|
|
370
|
-
silent.length === 0
|
|
371
|
-
? `every one of the ${declared} sound/effect nodes fired`
|
|
372
|
-
: `${firedDeclared} of ${declared} fired — silent: ${silent.slice(0, 3).join(', ')}${silent.length > 3 ? ` +${silent.length - 3}` : ''}`,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
}
|
|
398
|
+
// The wording lives in `verify-ladder.ts` (pure, and tested there).
|
|
399
|
+
// `undrivable` is the plays rung's own "the bot never got it moving", so the
|
|
400
|
+
// two never disagree about whether anything was played.
|
|
401
|
+
rungs.push(feelsRung(playtestReport?.feedback, { played: botPlayed }));
|
|
376
402
|
|
|
377
403
|
// ---- agrees --------------------------------------------------------------
|
|
378
404
|
// A desync is the purest silent failure this engine has: both clients run,
|
|
@@ -381,7 +407,7 @@ if (rungs[0].status === 'pass') {
|
|
|
381
407
|
{
|
|
382
408
|
const isMultiplayer = (() => {
|
|
383
409
|
try {
|
|
384
|
-
const doc =
|
|
410
|
+
const doc = readJson(scene);
|
|
385
411
|
return Boolean(doc?.multiplayer);
|
|
386
412
|
} catch {
|
|
387
413
|
return false;
|
|
@@ -449,28 +475,27 @@ if (rungs[0].status === 'pass') {
|
|
|
449
475
|
? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
|
|
450
476
|
: 'start your dev server, open the page, and run this again — the pixels are in the browser',
|
|
451
477
|
});
|
|
452
|
-
} else if (report.hidden) {
|
|
478
|
+
} else if (report.hidden && report.droveFrames === 0) {
|
|
453
479
|
/*
|
|
454
|
-
* A hidden page
|
|
480
|
+
* A hidden page cannot be DRIVEN, and that one is still unmeasured.
|
|
455
481
|
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
482
|
+
* The rung used to refuse any hidden page, on the belief that its pixels
|
|
483
|
+
* were "whatever it last drew". They are not: `captureFrame` in both
|
|
484
|
+
* renderers waits ~120 ms for a loop that a hidden tab never runs, and then
|
|
485
|
+
* draws a frame itself. Measured — hide every node from the console and
|
|
486
|
+
* capture again: 2D `luminance 0.09 → 0.00 BLACK SCREEN`, 3D `0.64 → 0.96,
|
|
487
|
+
* subject 44.3% → 22.0%`.
|
|
459
488
|
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* Two of six rungs reported ✓ off a stale buffer while the engine already
|
|
466
|
-
* knew, and said elsewhere, that the page was drawing nothing. The skills
|
|
467
|
-
* state the rule: an unmeasured rung is not a failure, and this is exactly
|
|
468
|
-
* the unmeasured case.
|
|
489
|
+
* So a plain capture is current and measurable, and the ONLY thing a hidden
|
|
490
|
+
* page cannot do is advance — which is exactly what a drive needs, and what
|
|
491
|
+
* `droveFrames: 0` reports. Refusing both cost this rung every measurement
|
|
492
|
+
* it could have made in the setup the whole toolchain is built for: a
|
|
493
|
+
* terminal in front and a browser behind it.
|
|
469
494
|
*/
|
|
470
495
|
rungs.push({
|
|
471
496
|
name: 'draws',
|
|
472
497
|
status: 'unmeasured',
|
|
473
|
-
summary: 'the
|
|
498
|
+
summary: 'the drive ran on a HIDDEN page — 0 frames drawn, so nothing it did shows here',
|
|
474
499
|
fix: 'bring the window to the front (one covered by another counts as hidden) and run this again',
|
|
475
500
|
});
|
|
476
501
|
} else if (report.black) {
|
|
@@ -503,6 +528,14 @@ if (rungs[0].status === 'pass') {
|
|
|
503
528
|
if (report.uniform) {
|
|
504
529
|
notes.push('one flat colour — the camera may be inside geometry, or only the sky is drawn');
|
|
505
530
|
}
|
|
531
|
+
// Measured, not refused — but the reader still has to know the world was
|
|
532
|
+
// not moving while it was drawn.
|
|
533
|
+
if (report.hidden) {
|
|
534
|
+
notes.push(
|
|
535
|
+
'the page is hidden, so the game is PAUSED — this frame was rendered for the ' +
|
|
536
|
+
'capture, and shows the moment it froze',
|
|
537
|
+
);
|
|
538
|
+
}
|
|
506
539
|
rungs.push({
|
|
507
540
|
name: 'draws',
|
|
508
541
|
status: 'pass',
|
|
@@ -528,21 +561,11 @@ if (rungs[0].status === 'pass') {
|
|
|
528
561
|
summary: firstLine(r.stderr) ?? 'no page to ask',
|
|
529
562
|
fix: 'open the preview page and run this again — the log buffer is in the browser',
|
|
530
563
|
});
|
|
531
|
-
} else if (report.ok) {
|
|
532
|
-
rungs.push({ name: 'says', status: 'pass', summary: 'no errors, no failed assets' });
|
|
533
564
|
} else {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// A game whose grass and water are fine still fails here when its player
|
|
539
|
-
// never loaded — which is the case this rung exists for, and the one
|
|
540
|
-
// `draws` calls healthy.
|
|
541
|
-
summary: asset
|
|
542
|
-
? `asset ${asset.ref} never loaded: ${asset.error}`
|
|
543
|
-
: (report.worst?.message ?? `${report.stats?.errors ?? 0} errors were swallowed`),
|
|
544
|
-
fix: 'read it in full: `incanto-logs`',
|
|
545
|
-
});
|
|
565
|
+
// What it MEANS lives in `verify-ladder` with the rest of the pure half —
|
|
566
|
+
// this rung was reading `ok` and dropping every warning the page reported,
|
|
567
|
+
// and there was nowhere to test that without a browser.
|
|
568
|
+
rungs.push(saysRung(report));
|
|
546
569
|
}
|
|
547
570
|
}
|
|
548
571
|
|
|
@@ -556,10 +579,6 @@ function safeJson(text) {
|
|
|
556
579
|
return null;
|
|
557
580
|
}
|
|
558
581
|
}
|
|
559
|
-
function firstLine(text) {
|
|
560
|
-
const line = (text ?? '').split('\n').find((l) => l.trim());
|
|
561
|
-
return line ? line.trim() : null;
|
|
562
|
-
}
|
|
563
582
|
|
|
564
583
|
const verdict = ladderVerdict(rungs);
|
|
565
584
|
if (asJson) {
|
|
@@ -576,4 +595,4 @@ if (asJson) {
|
|
|
576
595
|
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
577
596
|
// report read by another program came back truncated — silently, and only
|
|
578
597
|
// when piped, which is the only way a program reads it.
|
|
579
|
-
process.exitCode = verdict.ok ? 0 : 1;
|
|
598
|
+
process.exitCode = verdict.measured ? (verdict.ok ? 0 : 1) : 2;
|
package/bin/incanto.mjs
CHANGED
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
* the SAME runtime that started this one, which matters because `--behaviors`
|
|
24
24
|
* loads your TypeScript and bun and node disagree about what they can import.
|
|
25
25
|
*/
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
28
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
+
import { readJson } from './_read-json.mjs';
|
|
29
30
|
|
|
30
31
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
32
|
|
|
@@ -50,7 +51,7 @@ const COMMANDS = {
|
|
|
50
51
|
|
|
51
52
|
function version() {
|
|
52
53
|
try {
|
|
53
|
-
const pkg =
|
|
54
|
+
const pkg = readJson(join(HERE, '..', 'package.json'));
|
|
54
55
|
return pkg.version ?? 'unknown';
|
|
55
56
|
} catch {
|
|
56
57
|
return 'unknown';
|