incanto 0.57.0 → 0.59.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/README.md +6 -4
- package/bin/incanto-check.mjs +27 -0
- package/bin/incanto-multiplay.mjs +170 -0
- package/bin/incanto-new.mjs +29 -7
- package/bin/incanto-playtest.mjs +28 -2
- package/bin/incanto-verify.mjs +155 -19
- package/bin/incanto.mjs +107 -0
- package/dist/2d.d.ts +8 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +9 -3
- package/dist/3d.js +4 -4
- package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
- package/dist/{create-game-C5jQYPah.js → create-game-BiW8Men_.js} +61 -13
- package/dist/{create-game-DpbUrMOQ.js → create-game-CHDLDQsQ.js} +6 -6
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -2
- package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
- package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-DRAz5EV9.js} +12 -10
- package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +36 -8
- package/dist/index.js +8 -8
- package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
- package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
- package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
- package/dist/{physics-2d-BLcvEFDR.js → physics-2d-BaRSRrrZ.js} +14 -3
- package/dist/{physics-3d-QBrfIT2Y.js → physics-3d-CYxjh-HW.js} +15 -4
- package/dist/quiet-rapier-BAJ4K94N.js +46 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +2 -2
- package/dist/{register-C6ZBFRjd.js → register-BpFcgdcL.js} +57 -27
- package/dist/{register-Ch70uByv.js → register-CDrAQqPp.js} +90 -41
- package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
- package/dist/{replay-s7I2GstT.js → replay-CEPyQtF_.js} +31 -7
- package/dist/{replay-Dw6gMlYA.d.ts → replay-O-yAGM76.d.ts} +1 -1
- package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
- package/dist/{split-screen-DLsUrleX.js → split-screen-DDMZutQ6.js} +56 -13
- package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
- package/dist/{src-CGjmPw65.js → src-CY21B462.js} +1 -1
- package/dist/{teardown-Cs113S9F.js → teardown-RApWnM1G.js} +1 -1
- package/dist/{test-it1VekWs.js → test-DHYuFyAu.js} +292 -31
- package/dist/test.d.ts +104 -6
- package/dist/test.js +3 -3
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-CGT7r3Mb.js → agent8-BoRGtVxK.js} +1 -1
- package/editor/assets/{debug-BxWSIHG3.js → debug-CzdyCg75.js} +1 -1
- package/editor/assets/{index-CV1m-aX5.js → index-VesuVEhe.js} +91 -91
- package/editor/index.html +1 -1
- package/package.json +3 -1
- package/schemas/scene.schema.json +1174 -70
- package/skills/incanto-building-2d-games.md +13 -0
- package/skills/incanto-building-3d-games.md +3 -3
- package/skills/incanto-localization.md +40 -8
- package/skills/incanto-multiplayer.md +57 -4
- package/skills/incanto-node-reference.md +18 -0
- package/skills/incanto-physics-and-input.md +26 -0
- package/skills/incanto-playtesting.md +19 -5
- package/skills/incanto-verifying-your-game.md +33 -4
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
- package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
- package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
- package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
- package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
- package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
- package/templates-app/platformer-2d/index.html +99 -0
- package/templates-app/platformer-2d/package.json +23 -0
- package/templates-app/platformer-2d/src/behaviors.ts +541 -0
- package/templates-app/platformer-2d/src/game.scene.json +2061 -0
- package/templates-app/platformer-2d/src/main.ts +68 -0
- package/templates-app/platformer-2d/tsconfig.json +13 -0
- package/templates-app/platformer-2d/verify.ts +275 -0
- package/templates-app/platformer-2d/vite.config.ts +12 -0
- package/templates-app/star-survivor/PROJECT/Context.md +55 -0
- package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
- package/templates-app/star-survivor/PROJECT/Status.md +44 -0
- package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
- package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
- package/templates-app/star-survivor/index.html +232 -0
- package/templates-app/star-survivor/package.json +23 -0
- package/templates-app/star-survivor/src/behaviors.ts +624 -0
- package/templates-app/star-survivor/src/game.scene.json +464 -0
- package/templates-app/star-survivor/src/main.ts +49 -0
- package/templates-app/star-survivor/tsconfig.json +13 -0
- package/templates-app/star-survivor/verify.ts +193 -0
- package/templates-app/star-survivor/vite.config.ts +12 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/game.scene.json +6 -3
- package/templates-app/tps-3d/verify.ts +17 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
- package/templates-app/village-quest-3d/src/village.scene.json +5 -5
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ rule is JSON an AI agent can read, diff, and rewrite — rendered by three.js.
|
|
|
6
6
|
## Start with a whole game
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
-
bunx incanto
|
|
9
|
+
bunx incanto new my-game # Beacon Isle — the flagship 3D template
|
|
10
|
+
bunx incanto new my-2d --template platformer-2d # the 2D flagship
|
|
10
11
|
cd my-game && bun install && bun run dev
|
|
11
12
|
```
|
|
12
13
|
|
|
@@ -20,8 +21,9 @@ rest declared in JSON. `bun run verify` plays the entire quest **headlessly**
|
|
|
20
21
|
and replays recorded input bit-identically: the agent loop is author → verify →
|
|
21
22
|
fix, no browser needed.
|
|
22
23
|
|
|
23
|
-
`bunx incanto
|
|
24
|
-
quest vignette)
|
|
24
|
+
`bunx incanto new --list` shows all five starters, grouped by dimension: three
|
|
25
|
+
3D (island adventure, third-person shooter, quest vignette) and two 2D
|
|
26
|
+
(platformer, survivors-like).
|
|
25
27
|
|
|
26
28
|
## Or wire the engine yourself
|
|
27
29
|
|
|
@@ -76,7 +78,7 @@ in a world full of them). In code: `physics.debugDraw = true` + `physics.debugSc
|
|
|
76
78
|
|
|
77
79
|
## The agent8 asset library
|
|
78
80
|
|
|
79
|
-
`bunx incanto
|
|
81
|
+
`bunx incanto editor --token <v8 access token>` (or `INCANTO_V8_TOKEN`) adds a
|
|
80
82
|
**📚** button to every field that takes a resource — model, texture, sprite
|
|
81
83
|
sheet, sound. Shelves, search, real previews (a GLB is rendered by the engine
|
|
82
84
|
itself), and one pick writes the URL, or the scene `assets{}` entry plus its
|
package/bin/incanto-check.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* each scene file.
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
17
18
|
import { dirname, join, resolve } from 'node:path';
|
|
18
19
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
20
|
|
|
@@ -87,6 +88,15 @@ function missingArt(file, json) {
|
|
|
87
88
|
join(sceneDir, '..', rel),
|
|
88
89
|
];
|
|
89
90
|
if (candidates.some((p) => existsSync(p))) continue;
|
|
91
|
+
// A BARE PACKAGE SPECIFIER is a bundler import, not a path — and it is the
|
|
92
|
+
// form the assets skill teaches for built-in art
|
|
93
|
+
// (`incanto/assets/items/coin.png`). A scene had no way to say "this URL
|
|
94
|
+
// arrives from the bundler": a made-up placeholder (`$KNIGHT_URL`) got
|
|
95
|
+
// flagged here as art nobody copied, and an empty string — which the loop
|
|
96
|
+
// above still skips — is rejected outright by the loader. So the honest
|
|
97
|
+
// spelling was unwritable, and every 2D example that uses built-in art
|
|
98
|
+
// warned about art that works.
|
|
99
|
+
if (resolvesAsPackageAsset(url, sceneDir)) continue;
|
|
90
100
|
out.push(
|
|
91
101
|
`$${key} → ${url} is not in the project ` +
|
|
92
102
|
`(looked in ${relativeish(root, join(root, 'public'))}, ${relativeish(root, root)}` +
|
|
@@ -96,6 +106,23 @@ function missingArt(file, json) {
|
|
|
96
106
|
return out;
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Can this url be resolved as a file inside an installed package?
|
|
111
|
+
*
|
|
112
|
+
* `incanto/assets/items/coin.png` is what the bundler is handed and what the
|
|
113
|
+
* skills teach; if the package resolves it, the file is really there.
|
|
114
|
+
*/
|
|
115
|
+
function resolvesAsPackageAsset(url, from) {
|
|
116
|
+
if (url.startsWith('.') || url.startsWith('/')) return false;
|
|
117
|
+
// A bare specifier: `pkg/path` or `@scope/pkg/path`.
|
|
118
|
+
if (!/^(@[^/]+\/)?[^@/][^/]*\//.test(url)) return false;
|
|
119
|
+
try {
|
|
120
|
+
return existsSync(createRequire(join(from, 'noop.js')).resolve(url));
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
/** Nearest ancestor with a package.json, else the scene's own directory. */
|
|
100
127
|
function projectRoot(from) {
|
|
101
128
|
let dir = resolve(from);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* incanto-multiplay — run a real match headlessly and check the clients AGREE.
|
|
4
|
+
*
|
|
5
|
+
* bunx incanto multiplay src/game.scene.json
|
|
6
|
+
* bunx incanto multiplay src/game.scene.json --players 3 --seconds 8 --json
|
|
7
|
+
*
|
|
8
|
+
* A desync is the purest silent failure this engine can have: both clients
|
|
9
|
+
* run, neither errors, and they are playing different games. Nothing checked
|
|
10
|
+
* for one — the harness measured room MEMBERSHIP, so "saw p2" meant "p2 is in
|
|
11
|
+
* the room", not "p2's data arrived", and a game whose replication was
|
|
12
|
+
* completely dead reported exactly what a working one did.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS PROVES, AND WHAT IT DOES NOT. Every client runs against one
|
|
15
|
+
* in-memory server: no latency, no loss, no reordering. So this measures the
|
|
16
|
+
* APPLY path and the protocol shape, and says nothing about a live wire — a
|
|
17
|
+
* reconnect that never re-joins, a throttle that discards a payload, a batch
|
|
18
|
+
* dropped on a closed socket.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const flag = (name, fallback) => {
|
|
28
|
+
const i = argv.indexOf(name);
|
|
29
|
+
return i >= 0 ? argv[i + 1] : fallback;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
33
|
+
console.log(`Usage: incanto multiplay [scene.json] [options]
|
|
34
|
+
|
|
35
|
+
--players N clients in the match (default 2)
|
|
36
|
+
--seconds N simulated seconds (default 5)
|
|
37
|
+
--seed N make the match reproducible (default 1)
|
|
38
|
+
--behaviors FILE your Behavior subclasses (.ts works on bun / node >= 23.6)
|
|
39
|
+
--scenes a=b.json a scene a NetworkSpawner names, repeatable
|
|
40
|
+
--no-late-join skip the late-joining client
|
|
41
|
+
--json machine-readable report
|
|
42
|
+
|
|
43
|
+
Checks that every client holds the SAME value for every replicated key, plus
|
|
44
|
+
one client that joins AFTER the match settles — the only observer that reads
|
|
45
|
+
the authoritative snapshot fresh, and so the only one that can see a value
|
|
46
|
+
erased from it.`);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const named = argv.find((a) => !a.startsWith('-') && a.endsWith('.json'));
|
|
51
|
+
if (!named) {
|
|
52
|
+
console.error('incanto-multiplay: name the scene, e.g. src/game.scene.json');
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
const scenePath = resolve(named);
|
|
56
|
+
if (!existsSync(scenePath)) {
|
|
57
|
+
console.error(`no such scene: ${scenePath}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { playMultiplayer, multiplayText, registerAllNodes } = await import(
|
|
62
|
+
pathToFileURL(join(PKG, 'dist', 'test.js')).href
|
|
63
|
+
);
|
|
64
|
+
registerAllNodes();
|
|
65
|
+
|
|
66
|
+
const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
|
|
67
|
+
const sceneDir = dirname(scenePath);
|
|
68
|
+
|
|
69
|
+
// Without the game's own behaviours a scene that names one throws
|
|
70
|
+
// `Unknown behavior` before a single frame runs — so load them when we are
|
|
71
|
+
// given them, and stub what is left. A stubbed behaviour still lets the
|
|
72
|
+
// SCENE's replication run, which is what this command measures.
|
|
73
|
+
const behaviorsFile = flag('--behaviors', null);
|
|
74
|
+
if (behaviorsFile) {
|
|
75
|
+
try {
|
|
76
|
+
const mod = await import(pathToFileURL(resolve(behaviorsFile)).href);
|
|
77
|
+
const { registerBehavior, Behavior } = await import(
|
|
78
|
+
pathToFileURL(join(PKG, 'dist', 'index.js')).href
|
|
79
|
+
);
|
|
80
|
+
for (const [name, value] of Object.entries(mod)) {
|
|
81
|
+
if (typeof value === 'function' && value.prototype instanceof Behavior) {
|
|
82
|
+
registerBehavior(name, value, { replace: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error(`could not load --behaviors '${behaviorsFile}' (${e?.message ?? e}).`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scenes a `NetworkSpawner` names.
|
|
93
|
+
*
|
|
94
|
+
* `--scenes remote-player=src/remote.scene.json` is explicit; otherwise every
|
|
95
|
+
* spawner's `scene` prop is looked for as a sibling file, which is where the
|
|
96
|
+
* templates put them. A spawner whose scene cannot be found materialises
|
|
97
|
+
* nothing, and the agreement check reports that as `missing` rather than
|
|
98
|
+
* letting it read as "this game has no remote players".
|
|
99
|
+
*/
|
|
100
|
+
const scenes = {};
|
|
101
|
+
for (let i = 0; i < argv.length; i++) {
|
|
102
|
+
if (argv[i] !== '--scenes') continue;
|
|
103
|
+
const [key, file] = String(argv[i + 1] ?? '').split('=');
|
|
104
|
+
if (key && file) scenes[key] = JSON.parse(readFileSync(resolve(sceneDir, file), 'utf-8'));
|
|
105
|
+
}
|
|
106
|
+
const spawnerScenes = new Set();
|
|
107
|
+
const walk = (node) => {
|
|
108
|
+
if (node?.type === 'NetworkSpawner' && typeof node.props?.scene === 'string') {
|
|
109
|
+
spawnerScenes.add(node.props.scene);
|
|
110
|
+
}
|
|
111
|
+
for (const child of node?.children ?? []) walk(child);
|
|
112
|
+
};
|
|
113
|
+
walk(sceneJson.root);
|
|
114
|
+
for (const name of spawnerScenes) {
|
|
115
|
+
if (scenes[name]) continue;
|
|
116
|
+
for (const guess of [`${name}.scene.json`, `${name}.json`]) {
|
|
117
|
+
const at = resolve(sceneDir, guess);
|
|
118
|
+
if (existsSync(at)) {
|
|
119
|
+
scenes[name] = JSON.parse(readFileSync(at, 'utf-8'));
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let report;
|
|
126
|
+
try {
|
|
127
|
+
report = await playMultiplayer({
|
|
128
|
+
scene: sceneJson,
|
|
129
|
+
scenes,
|
|
130
|
+
players: Number(flag('--players', 2)) || 2,
|
|
131
|
+
seconds: Number(flag('--seconds', 5)) || 5,
|
|
132
|
+
seed: Number(flag('--seed', 1)) || 1,
|
|
133
|
+
lateJoin: !argv.includes('--no-late-join'),
|
|
134
|
+
load: {
|
|
135
|
+
stubMissingBehaviors: true,
|
|
136
|
+
resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
|
|
137
|
+
},
|
|
138
|
+
// Move the owner, or every replicated key holds its authored value and two
|
|
139
|
+
// motionless clients agree about nothing in particular.
|
|
140
|
+
drive: (player, frame) => {
|
|
141
|
+
const root = player.engine.scene?.root;
|
|
142
|
+
const owner = root && findOwner(root);
|
|
143
|
+
if (!owner || !Array.isArray(owner.position)) return;
|
|
144
|
+
const spin = (player.account.charCodeAt(1) || 1) * 0.7;
|
|
145
|
+
owner.position = owner.position.map((v, i) =>
|
|
146
|
+
i === 1 && owner.position.length === 3 ? v : Math.sin(frame / 20 + spin + i) * 4,
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(`multiplay failed: ${e?.message ?? e}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The single `network.mode === 'owner'` node, the way the manager finds it. */
|
|
156
|
+
function findOwner(node) {
|
|
157
|
+
if (node.network?.mode === 'owner') return node;
|
|
158
|
+
for (const child of node.children ?? []) {
|
|
159
|
+
const found = findOwner(child);
|
|
160
|
+
if (found) return found;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (argv.includes('--json')) {
|
|
166
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
167
|
+
} else {
|
|
168
|
+
process.stdout.write(`${multiplayText(report)}\n`);
|
|
169
|
+
}
|
|
170
|
+
process.exit(report.ok ? 0 : 1);
|
package/bin/incanto-new.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* incanto-new — scaffold a ready-to-run incanto game from a shipped template:
|
|
4
4
|
*
|
|
5
|
-
* bunx incanto
|
|
6
|
-
* bunx incanto
|
|
7
|
-
* bunx incanto
|
|
5
|
+
* bunx incanto new my-game # Beacon Isle (3D flagship)
|
|
6
|
+
* bunx incanto new my-game --template tps-3d # third-person shooter
|
|
7
|
+
* bunx incanto new --list
|
|
8
8
|
*
|
|
9
9
|
* Copies the template, names the package after the directory, prints the
|
|
10
10
|
* three commands that get you playing. Templates are real npm consumers —
|
|
@@ -19,11 +19,25 @@ const TEMPLATES_DIR = join(PKG, 'templates-app');
|
|
|
19
19
|
|
|
20
20
|
const DESCRIPTIONS = {
|
|
21
21
|
'beacon-isle-3d':
|
|
22
|
-
'
|
|
23
|
-
'village-quest-3d': '
|
|
22
|
+
'island action-adventure (the 3D flagship): generated world, quest NPC, terrain-nav enemies, melee',
|
|
23
|
+
'village-quest-3d': 'quest vignette: dialogue, scene transitions, patrol AI, sword combat',
|
|
24
24
|
'tps-3d': 'third-person arena shooter: GLB soldier, hitscan rifle, enemy waves',
|
|
25
|
+
'platformer-2d':
|
|
26
|
+
'side-scrolling platformer (the 2D flagship): tilemap level, coyote-time jump, follow cam, coins',
|
|
27
|
+
'star-survivor':
|
|
28
|
+
'survivors-like: waves that never stop, auto-attack, upgrades, a clock to outlast',
|
|
25
29
|
};
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Which half of the engine a starter belongs to.
|
|
33
|
+
*
|
|
34
|
+
* The list was three templates, all 3D, printed in one alphabetical column —
|
|
35
|
+
* while the engine shipped a whole 2D half with its own nodes, physics,
|
|
36
|
+
* tilemaps and skill. Someone building a 2D game read that list and concluded
|
|
37
|
+
* there was nothing for them.
|
|
38
|
+
*/
|
|
39
|
+
const DIMENSION = (name) => (name.endsWith('-2d') || name === 'star-survivor' ? '2d' : '3d');
|
|
40
|
+
|
|
27
41
|
function listTemplates() {
|
|
28
42
|
if (!existsSync(TEMPLATES_DIR)) return [];
|
|
29
43
|
return readdirSync(TEMPLATES_DIR, { withFileTypes: true })
|
|
@@ -34,9 +48,17 @@ function listTemplates() {
|
|
|
34
48
|
|
|
35
49
|
const argv = process.argv.slice(2);
|
|
36
50
|
if (argv.includes('--list') || argv.includes('-l')) {
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
const all = listTemplates();
|
|
52
|
+
for (const [dim, heading] of [
|
|
53
|
+
['3d', '3D'],
|
|
54
|
+
['2d', '2D'],
|
|
55
|
+
]) {
|
|
56
|
+
const group = all.filter((t) => DIMENSION(t) === dim);
|
|
57
|
+
if (group.length === 0) continue;
|
|
58
|
+
console.log(`\n ${heading}`);
|
|
59
|
+
for (const t of group) console.log(` ${t.padEnd(20)} ${DESCRIPTIONS[t] ?? ''}`);
|
|
39
60
|
}
|
|
61
|
+
console.log('\n bunx incanto new my-game --template <name> (default: beacon-isle-3d)\n');
|
|
40
62
|
process.exit(0);
|
|
41
63
|
}
|
|
42
64
|
|
package/bin/incanto-playtest.mjs
CHANGED
|
@@ -78,9 +78,35 @@ if (args.behaviors) {
|
|
|
78
78
|
try {
|
|
79
79
|
mod = await import(pathToFileURL(resolve(args.behaviors)).href);
|
|
80
80
|
} catch (e) {
|
|
81
|
+
const why = e?.message ?? String(e);
|
|
82
|
+
// NODE CANNOT IMPORT THE PATTERN WE TEACH. Every template's behaviors.ts
|
|
83
|
+
// does `import gameJson from './game.scene.json'` — the documented way to
|
|
84
|
+
// read your own scene — and node's type stripping refuses a JSON import
|
|
85
|
+
// without `with { type: 'json' }`. Vite is fine with it, bun is fine with
|
|
86
|
+
// it, and this bin's shebang is node, so `--behaviors` failed on the
|
|
87
|
+
// engine's own flagship template. Hand the work to bun rather than telling
|
|
88
|
+
// the author their file is wrong: it is not.
|
|
89
|
+
const nodeCannotReadIt = /import attribute|Unknown file extension/i.test(why);
|
|
90
|
+
if (nodeCannotReadIt && !process.versions.bun && !process.env.INCANTO_BUN_REEXEC) {
|
|
91
|
+
const { spawnSync } = await import('node:child_process');
|
|
92
|
+
const hasBun = spawnSync('bun', ['--version'], { stdio: 'ignore' }).status === 0;
|
|
93
|
+
if (hasBun) {
|
|
94
|
+
const here = fileURLToPath(import.meta.url);
|
|
95
|
+
const again = spawnSync('bun', [here, ...process.argv.slice(2)], {
|
|
96
|
+
stdio: 'inherit',
|
|
97
|
+
env: { ...process.env, INCANTO_BUN_REEXEC: '1' },
|
|
98
|
+
});
|
|
99
|
+
process.exit(again.status ?? 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
81
102
|
console.error(
|
|
82
|
-
`could not load --behaviors '${args.behaviors}' (${
|
|
83
|
-
|
|
103
|
+
`could not load --behaviors '${args.behaviors}' (${why}).` +
|
|
104
|
+
(nodeCannotReadIt
|
|
105
|
+
? '\n\nThis is node refusing your file, not a problem with it: node cannot' +
|
|
106
|
+
"\nimport JSON from a .ts module without `with { type: 'json' }`, and every" +
|
|
107
|
+
'\nincanto template imports its scene that way. Run it with bun, which' +
|
|
108
|
+
'\nreads the file as written: `bunx incanto playtest <scene> --behaviors <file>`.'
|
|
109
|
+
: '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.'),
|
|
84
110
|
);
|
|
85
111
|
process.exit(1);
|
|
86
112
|
}
|
package/bin/incanto-verify.mjs
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
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, statSync } from 'node:fs';
|
|
23
|
-
import { dirname, join } from 'node:path';
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
23
|
+
import { dirname, join, relative } from 'node:path';
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
25
|
|
|
26
26
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -35,18 +35,21 @@ const flag = (name) => {
|
|
|
35
35
|
return i >= 0 ? argv[i + 1] : null;
|
|
36
36
|
};
|
|
37
37
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
38
|
-
console.log(`Usage: incanto
|
|
38
|
+
console.log(`Usage: incanto verify [scene.json] [--behaviors FILE] [--json]
|
|
39
39
|
|
|
40
40
|
Walks the whole ladder and tells you the ONE thing to do next:
|
|
41
41
|
|
|
42
42
|
loads the scene is legal and every asset resolves (incanto-check)
|
|
43
43
|
plays a seeded run can actually finish it (incanto-playtest)
|
|
44
|
+
feels the sounds and effects it declares actually fired (engine.audio/effects)
|
|
45
|
+
agrees two clients hold the same replicated values (incanto-multiplay)
|
|
44
46
|
draws the GPU drew something and the subject is in shot (incanto-frame)
|
|
45
47
|
says the running game logs no errors and lost no assets (incanto-logs)
|
|
46
48
|
|
|
47
49
|
Without a scene it looks for one *.scene.json under the current directory.
|
|
48
|
-
"draws" needs a dev server with the page open
|
|
49
|
-
|
|
50
|
+
"draws" needs a dev server with the page open, and "agrees" needs a scene with
|
|
51
|
+
a "multiplayer" header; without either they are reported as NOT MEASURED rather
|
|
52
|
+
than failed — a missing measurement is not a broken game.`);
|
|
50
53
|
process.exit(0);
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -102,7 +105,34 @@ if (!existsSync(scene) || !statSync(scene).isFile()) {
|
|
|
102
105
|
process.exit(1);
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Find the file that holds the game's Behavior subclasses.
|
|
110
|
+
*
|
|
111
|
+
* The scene was auto-discovered from the start; the behaviours were not, and
|
|
112
|
+
* without them a playtest runs the STRUCTURE with every script stubbed. On the
|
|
113
|
+
* engine's own flagship template that means the quest never advances, so bare
|
|
114
|
+
* `incanto-verify` — the exact command the README prints — reported `plays` as
|
|
115
|
+
* a failure and told the reader to re-run a different tool by hand, with the
|
|
116
|
+
* path to a file sitting right next to the scene it had already found.
|
|
117
|
+
*/
|
|
118
|
+
function findBehaviors(sceneFile) {
|
|
119
|
+
const named = flag('--behaviors');
|
|
120
|
+
if (named) return { file: named, guessed: false };
|
|
121
|
+
const near = dirname(sceneFile);
|
|
122
|
+
for (const dir of [near, join(near, '..'), process.cwd(), join(process.cwd(), 'src')]) {
|
|
123
|
+
for (const base of ['behaviors', 'behaviours']) {
|
|
124
|
+
for (const ext of ['.ts', '.js', '.mjs']) {
|
|
125
|
+
const candidate = join(dir, base + ext);
|
|
126
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
127
|
+
return { file: candidate, guessed: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { file: null, guessed: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { file: behaviors, guessed: guessedBehaviors } = findBehaviors(scene);
|
|
106
136
|
const rungs = [];
|
|
107
137
|
|
|
108
138
|
// ---- loads ---------------------------------------------------------------
|
|
@@ -145,25 +175,61 @@ if (rungs[0].status === 'pass') {
|
|
|
145
175
|
// walkabout template has no end, and calling that a failure sends its author
|
|
146
176
|
// hunting a bug that was never there.
|
|
147
177
|
const noGoal = out && out.declaresWin === false;
|
|
178
|
+
// A quest — talk to the NPC, clear the enemies, light the wards — cannot be
|
|
179
|
+
// finished by a random walker, ever. Reporting that as a FAILED rung means
|
|
180
|
+
// the headline command permanently says NOT verified about a correct game,
|
|
181
|
+
// which teaches its author to stop reading it. If every run played to the
|
|
182
|
+
// end of its budget without erroring, falling or wedging, the rung has not
|
|
183
|
+
// failed: it has not measured, and the author's own scripted harness is what
|
|
184
|
+
// can judge this game.
|
|
185
|
+
// Three of the five outcomes are GAMEPLAY, not defects. `won`, `lost` and
|
|
186
|
+
// `unfinished` all mean the game ran; a random player dying half the time in
|
|
187
|
+
// a platformer is the hazards working. The defects are `error` (a behaviour
|
|
188
|
+
// threw), `fell` (left the world) and `stuck` (went nowhere), and those are
|
|
189
|
+
// what this rung is for.
|
|
190
|
+
const PLAYED = new Set(['won', 'lost', 'unfinished']);
|
|
191
|
+
const playedOut =
|
|
192
|
+
out && (out.runs?.length ?? 0) > 0 && out.runs.every((x) => PLAYED.has(x.outcome));
|
|
193
|
+
const tally = (name) => out?.runs?.filter((x) => x.outcome === name).length ?? 0;
|
|
148
194
|
rungs.push(
|
|
149
195
|
r.status === 0
|
|
150
196
|
? { name: 'plays', status: 'pass', summary: `${won} of ${total} seeded runs finished it` }
|
|
151
|
-
:
|
|
197
|
+
: playedOut
|
|
152
198
|
? {
|
|
153
199
|
name: 'plays',
|
|
154
200
|
status: 'unmeasured',
|
|
155
|
-
summary: `nothing declares a win — ${total} runs played without error, and there was no end to reach`,
|
|
156
|
-
fix: 'if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)',
|
|
157
|
-
}
|
|
158
|
-
: {
|
|
159
|
-
name: 'plays',
|
|
160
|
-
status: 'fail',
|
|
161
201
|
summary:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
202
|
+
`${total} runs played without reaching a win` +
|
|
203
|
+
` (${[
|
|
204
|
+
tally('lost') && `${tally('lost')} lost`,
|
|
205
|
+
tally('unfinished') && `${tally('unfinished')} ran out the clock`,
|
|
206
|
+
]
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.join(', ')})`,
|
|
209
|
+
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`',
|
|
210
|
+
}
|
|
211
|
+
: noGoal
|
|
212
|
+
? {
|
|
213
|
+
name: 'plays',
|
|
214
|
+
status: 'unmeasured',
|
|
215
|
+
summary: `nothing declares a win — ${total} runs played without error, and there was no end to reach`,
|
|
216
|
+
fix: 'if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)',
|
|
217
|
+
}
|
|
218
|
+
: {
|
|
219
|
+
name: 'plays',
|
|
220
|
+
status: 'fail',
|
|
221
|
+
summary:
|
|
222
|
+
total > 0
|
|
223
|
+
? `no run finished it (${total} tried)`
|
|
224
|
+
: // "could not run" with no reason is the tool doing to its
|
|
225
|
+
// reader exactly what this whole ladder exists to prevent:
|
|
226
|
+
// reporting a failure it already knows the cause of. The
|
|
227
|
+
// child printed one; pass it on.
|
|
228
|
+
`the playtest could not run — ${firstLine(r.stderr) || `exit ${r.status}`}`,
|
|
229
|
+
fix: behaviors
|
|
230
|
+
? `see which runs stalled and where: \`incanto-playtest ${scene} --behaviors ${behaviors}\``
|
|
231
|
+
: `run it with your behaviours — without them the structure plays and your game logic does not: \`incanto-playtest ${scene} --behaviors src/behaviors.ts\``,
|
|
232
|
+
},
|
|
167
233
|
);
|
|
168
234
|
} else {
|
|
169
235
|
rungs.push({ name: 'plays', status: 'skipped', summary: 'not run — the scene does not load' });
|
|
@@ -211,6 +277,67 @@ if (rungs[0].status === 'pass') {
|
|
|
211
277
|
}
|
|
212
278
|
}
|
|
213
279
|
|
|
280
|
+
// ---- agrees --------------------------------------------------------------
|
|
281
|
+
// A desync is the purest silent failure this engine has: both clients run,
|
|
282
|
+
// neither errors, and they are playing different games. Skipped outright for a
|
|
283
|
+
// single-player game, because a rung that always passes is decoration.
|
|
284
|
+
{
|
|
285
|
+
const isMultiplayer = (() => {
|
|
286
|
+
try {
|
|
287
|
+
const doc = JSON.parse(readFileSync(scene, 'utf-8'));
|
|
288
|
+
return Boolean(doc?.multiplayer);
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
if (!isMultiplayer) {
|
|
294
|
+
rungs.push({
|
|
295
|
+
name: 'agrees',
|
|
296
|
+
status: 'skipped',
|
|
297
|
+
summary: 'not run — this scene has no `multiplayer` header',
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
const args = [scene, '--json', '--seconds', '4'];
|
|
301
|
+
if (behaviors) args.push('--behaviors', behaviors);
|
|
302
|
+
const r = run('multiplay', args);
|
|
303
|
+
const out = safeJson(r.stdout);
|
|
304
|
+
if (!out) {
|
|
305
|
+
rungs.push({
|
|
306
|
+
name: 'agrees',
|
|
307
|
+
status: 'unmeasured',
|
|
308
|
+
summary: `the match could not run — ${firstLine(r.stderr) || `exit ${r.status}`}`,
|
|
309
|
+
fix: `see it in full: \`incanto multiplay ${scene}\``,
|
|
310
|
+
});
|
|
311
|
+
} else if (out.keysCompared === 0) {
|
|
312
|
+
// Nothing moved, so nothing was compared. Saying "pass" here is how a
|
|
313
|
+
// rung becomes decorative.
|
|
314
|
+
rungs.push({
|
|
315
|
+
name: 'agrees',
|
|
316
|
+
status: 'unmeasured',
|
|
317
|
+
summary: 'no owner node declared any sync keys, so agreement was not tested',
|
|
318
|
+
fix: 'give the player node `network: { mode: "owner", sync: ["position"] }`',
|
|
319
|
+
});
|
|
320
|
+
} else if (out.ok) {
|
|
321
|
+
rungs.push({
|
|
322
|
+
name: 'agrees',
|
|
323
|
+
status: 'pass',
|
|
324
|
+
summary:
|
|
325
|
+
`${out.keysCompared} replicated value(s) match across clients` +
|
|
326
|
+
`${out.lateJoinChecked ? ' and a late joiner' : ''}` +
|
|
327
|
+
' — over one in-memory server, not a live wire',
|
|
328
|
+
});
|
|
329
|
+
} else {
|
|
330
|
+
const first = out.disagreements?.[0];
|
|
331
|
+
rungs.push({
|
|
332
|
+
name: 'agrees',
|
|
333
|
+
status: 'fail',
|
|
334
|
+
summary: first ? first.summary : (out.problems?.[0] ?? 'the clients disagreed'),
|
|
335
|
+
fix: `every disagreement: \`incanto multiplay ${scene}\``,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
214
341
|
// ---- draws ---------------------------------------------------------------
|
|
215
342
|
{
|
|
216
343
|
const r = run('frame', ['--json']);
|
|
@@ -298,7 +425,16 @@ function firstLine(text) {
|
|
|
298
425
|
}
|
|
299
426
|
|
|
300
427
|
const verdict = ladderVerdict(rungs);
|
|
301
|
-
|
|
428
|
+
if (asJson) {
|
|
429
|
+
console.log(JSON.stringify({ scene, behaviors, ...verdict }, null, 2));
|
|
430
|
+
} else {
|
|
431
|
+
if (guessedBehaviors) {
|
|
432
|
+
console.log(
|
|
433
|
+
`· behaviours: ${relative(process.cwd(), behaviors) || behaviors} (found, not named)`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
console.log(ladderText(verdict));
|
|
437
|
+
}
|
|
302
438
|
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
303
439
|
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
304
440
|
// report read by another program came back truncated — silently, and only
|