incanto 0.69.0 → 0.70.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 +125 -44
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +281 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +7 -7
- package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-DaMxqfNE.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-DZExDn9o.d.ts} +744 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-Bwvh6q8A.js} +142 -59
- package/dist/{create-game-CvJ1wSRP.js → create-game-C7ffQWW7.js} +97 -47
- 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 +1065 -863
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-D6Q5BxeE.js} +298 -45
- package/dist/{gameplay-D1KixWK8.js → gameplay-BfHkuzVb.js} +809 -211
- package/dist/gameplay.d.ts +187 -7
- package/dist/gameplay.js +1 -1
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +156 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-Cff09LMm.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-CE0Qvy3V.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CpH-2gn5.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-CQJ_PJKh.js} +103 -12
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-mmfugpaQ.js → register-6DYnKZcy.js} +546 -817
- package/dist/{register-DbVArsns.js → register-Bkk0wSDB.js} +332 -23
- package/dist/{replay-BEHLDcQV.js → replay-DjAkAzMq.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-Dmw-PKQu.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-BXVg148r.js} +4570 -2322
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-PL78oVXP.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-Cxfiv1Hg.js} +2 -17
- package/dist/test-iHYVUcDK.js +4036 -0
- package/dist/test.d.ts +531 -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 +176 -6
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-D0MS174y.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-BnXkKuYu.js} +2 -2
- package/editor/assets/index-CIu3uc3l.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +30 -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 +83 -6
- package/skills/incanto-building-2d-games.md +77 -8
- 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 +109 -19
- package/skills/incanto-hud.md +128 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +222 -58
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +78 -2
- package/skills/incanto-save-slots.md +161 -6
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +196 -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 +103 -378
- 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 +1255 -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 +128 -600
- 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 +41 -195
- 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 +78 -217
- 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 +21 -0
- package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +199 -838
- 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 { firstLine, ladderText, ladderVerdict, saysRung } = 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']) {
|
|
@@ -275,31 +303,62 @@ if (rungs[0].status === 'pass') {
|
|
|
275
303
|
.join(', ') +
|
|
276
304
|
')' +
|
|
277
305
|
(won > 0 ? ` — ${won} did finish it` : ''),
|
|
306
|
+
// The throw itself, not a command to go and find it. This rung used to
|
|
307
|
+
// say "read the throw: incanto-playtest …" — sending its reader to
|
|
308
|
+
// another tool for a line this JSON already carries.
|
|
309
|
+
notes: (out?.errorMessages ?? []).slice(0, 3),
|
|
278
310
|
fix:
|
|
279
311
|
tally('error') > 0
|
|
280
|
-
? `
|
|
312
|
+
? `fix the script named above, then re-run \`incanto-verify ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``
|
|
281
313
|
: tally('fell') > 0
|
|
282
314
|
? `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
315
|
: `nothing moved — check the input map and the controller: \`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``,
|
|
284
316
|
});
|
|
285
|
-
} else if (
|
|
317
|
+
} else if (won > 0) {
|
|
318
|
+
/*
|
|
319
|
+
* `r.status === 0` used to stand in for "it finished the game", and the
|
|
320
|
+
* exit code stopped meaning that: `incanto-playtest` now exits 0 for a
|
|
321
|
+
* scene with nobody to drive, because never being asked is not a failure.
|
|
322
|
+
* This rung read that 0 and reported
|
|
323
|
+
*
|
|
324
|
+
* ✓ plays — 0 of 8 seeded runs finished it
|
|
325
|
+
*
|
|
326
|
+
* on a scene with no character in it, right where it had been careful to
|
|
327
|
+
* say `? plays — nothing here is drivable`. The report has `runs`; ask it
|
|
328
|
+
* what happened rather than asking the child how it felt about it.
|
|
329
|
+
*/
|
|
286
330
|
rungs.push({
|
|
287
331
|
name: 'plays',
|
|
288
332
|
status: 'pass',
|
|
289
333
|
summary: `${won} of ${total} seeded runs finished it`,
|
|
290
334
|
});
|
|
291
335
|
} else if (undrivable) {
|
|
336
|
+
/*
|
|
337
|
+
* "Nobody to move" was said about games the bot had PLAYED.
|
|
338
|
+
*
|
|
339
|
+
* The driver has driven the mouse for a while, and this rung only ever
|
|
340
|
+
* asked whether a character could walk — so a flip puzzle whose every tile
|
|
341
|
+
* the bot clicked, whose `clicked` wires all fired and whose board changed
|
|
342
|
+
* state, was told to add a character controller. A whole class of games —
|
|
343
|
+
* match-3, tower defense, cards, point-and-click, RTS, board games — has no
|
|
344
|
+
* character to add, and the advice was to add one anyway.
|
|
345
|
+
*/
|
|
346
|
+
const clicks = (out?.runs ?? []).reduce((n, r) => n + (r.clicks ?? 0), 0);
|
|
292
347
|
rungs.push({
|
|
293
348
|
name: 'plays',
|
|
294
349
|
status: 'unmeasured',
|
|
295
350
|
summary:
|
|
296
|
-
|
|
297
|
-
? `
|
|
298
|
-
:
|
|
351
|
+
clicks > 0
|
|
352
|
+
? `played with the MOUSE — ${clicks} click(s) across ${total} runs, and no win to reach`
|
|
353
|
+
: out?.hasPlayer === false
|
|
354
|
+
? `nothing here is drivable — ${total} runs had nobody to move and nothing to click`
|
|
355
|
+
: `${total} runs went nowhere, and your behaviours were not loaded — that is most of what moves a character`,
|
|
299
356
|
fix:
|
|
300
|
-
|
|
301
|
-
? '
|
|
302
|
-
:
|
|
357
|
+
clicks > 0
|
|
358
|
+
? 'a mouse game is played, not walked — emit `won`/`lost` and this rung can pass'
|
|
359
|
+
: out?.hasPlayer === false
|
|
360
|
+
? 'give the player a character controller, the `player` group, or the name Player — or `Clickable` on what the cursor drives'
|
|
361
|
+
: `name them: \`incanto-verify ${scene} --behaviors src/behaviors.ts\``,
|
|
303
362
|
});
|
|
304
363
|
} else if (noGoal) {
|
|
305
364
|
rungs.push({
|
|
@@ -317,9 +376,22 @@ if (rungs[0].status === 'pass') {
|
|
|
317
376
|
rungs.push({
|
|
318
377
|
name: 'plays',
|
|
319
378
|
status: 'unmeasured',
|
|
379
|
+
// The parenthetical is only worth printing when it has something in it.
|
|
380
|
+
// It listed `lost` and `unfinished` and nothing else, so a game whose
|
|
381
|
+
// runs all ended at a SCENE SWAP — every tally zero — reported
|
|
382
|
+
// `8 runs played without reaching a win ()`, an empty bracket where the
|
|
383
|
+
// reason should be. `swapped` IS the reason, and it is the one outcome
|
|
384
|
+
// here that explains itself.
|
|
320
385
|
summary:
|
|
321
386
|
`${total} runs played without reaching a win` +
|
|
322
|
-
|
|
387
|
+
(() => {
|
|
388
|
+
const why = [
|
|
389
|
+
count(tally('lost'), 'lost'),
|
|
390
|
+
count(tally('unfinished'), 'ran out the clock'),
|
|
391
|
+
count(tally('swapped'), 'changed scene, where this bot stops measuring'),
|
|
392
|
+
].filter(Boolean);
|
|
393
|
+
return why.length > 0 ? ` (${why.join(', ')})` : '';
|
|
394
|
+
})(),
|
|
323
395
|
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
396
|
});
|
|
325
397
|
}
|
|
@@ -363,13 +435,36 @@ if (rungs[0].status === 'pass') {
|
|
|
363
435
|
// arithmetic that cannot be true: `✓ feels — 12 of 9 fired`. The number has
|
|
364
436
|
// to add up with the `silent` list beside it.
|
|
365
437
|
const firedDeclared = declaredPaths.length - silent.length;
|
|
438
|
+
/*
|
|
439
|
+
* A rung that exists because "wired but never triggered plays perfectly and
|
|
440
|
+
* feels dead, and every other rung here calls it healthy" — and which then
|
|
441
|
+
* called it healthy itself. `fired.size === 0` failed and ANYTHING else
|
|
442
|
+
* passed, so the flagship starter reported
|
|
443
|
+
*
|
|
444
|
+
* ✓ feels — 13 of 26 fired — silent: /Game/Ward1/ChimeSfx …
|
|
445
|
+
*
|
|
446
|
+
* A tick next to thirteen emitters nobody heard is the same claim the rung
|
|
447
|
+
* was written to stop.
|
|
448
|
+
*
|
|
449
|
+
* It is not a FAILURE either: a random bot does not light three wards, so a
|
|
450
|
+
* win chime it never reached is not a broken wire. That is exactly what
|
|
451
|
+
* `unmeasured` means in this ladder — the tool did not get to look — and it
|
|
452
|
+
* is the one status that cannot be read as approval.
|
|
453
|
+
*/
|
|
366
454
|
rungs.push({
|
|
367
455
|
name: 'feels',
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
456
|
+
...(silent.length === 0
|
|
457
|
+
? { status: 'pass', summary: `every one of the ${declared} sound/effect nodes fired` }
|
|
458
|
+
: {
|
|
459
|
+
status: 'unmeasured',
|
|
460
|
+
summary:
|
|
461
|
+
`${firedDeclared} of ${declared} fired — silent: ${silent.slice(0, 3).join(', ')}` +
|
|
462
|
+
`${silent.length > 3 ? ` +${silent.length - 3}` : ''}`,
|
|
463
|
+
fix:
|
|
464
|
+
'a silent emitter is either a broken wire or a moment random play never ' +
|
|
465
|
+
'reached — the two look identical from here. Reach it in a scripted run ' +
|
|
466
|
+
'(`runScript` from `incanto/test`), and this rung can tell you which.',
|
|
467
|
+
}),
|
|
373
468
|
});
|
|
374
469
|
}
|
|
375
470
|
}
|
|
@@ -381,7 +476,7 @@ if (rungs[0].status === 'pass') {
|
|
|
381
476
|
{
|
|
382
477
|
const isMultiplayer = (() => {
|
|
383
478
|
try {
|
|
384
|
-
const doc =
|
|
479
|
+
const doc = readJson(scene);
|
|
385
480
|
return Boolean(doc?.multiplayer);
|
|
386
481
|
} catch {
|
|
387
482
|
return false;
|
|
@@ -528,21 +623,11 @@ if (rungs[0].status === 'pass') {
|
|
|
528
623
|
summary: firstLine(r.stderr) ?? 'no page to ask',
|
|
529
624
|
fix: 'open the preview page and run this again — the log buffer is in the browser',
|
|
530
625
|
});
|
|
531
|
-
} else if (report.ok) {
|
|
532
|
-
rungs.push({ name: 'says', status: 'pass', summary: 'no errors, no failed assets' });
|
|
533
626
|
} 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
|
-
});
|
|
627
|
+
// What it MEANS lives in `verify-ladder` with the rest of the pure half —
|
|
628
|
+
// this rung was reading `ok` and dropping every warning the page reported,
|
|
629
|
+
// and there was nowhere to test that without a browser.
|
|
630
|
+
rungs.push(saysRung(report));
|
|
546
631
|
}
|
|
547
632
|
}
|
|
548
633
|
|
|
@@ -556,10 +641,6 @@ function safeJson(text) {
|
|
|
556
641
|
return null;
|
|
557
642
|
}
|
|
558
643
|
}
|
|
559
|
-
function firstLine(text) {
|
|
560
|
-
const line = (text ?? '').split('\n').find((l) => l.trim());
|
|
561
|
-
return line ? line.trim() : null;
|
|
562
|
-
}
|
|
563
644
|
|
|
564
645
|
const verdict = ladderVerdict(rungs);
|
|
565
646
|
if (asJson) {
|
|
@@ -576,4 +657,4 @@ if (asJson) {
|
|
|
576
657
|
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
577
658
|
// report read by another program came back truncated — silently, and only
|
|
578
659
|
// when piped, which is the only way a program reads it.
|
|
579
|
-
process.exitCode = verdict.ok ? 0 : 1;
|
|
660
|
+
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';
|