incanto 0.1.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.
Files changed (138) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.md +88 -0
  4. package/assets/audio/attacked.mp3 +0 -0
  5. package/assets/audio/explosion.mp3 +0 -0
  6. package/assets/audio/gold_loot.mp3 +0 -0
  7. package/assets/audio/heal.mp3 +0 -0
  8. package/assets/audio/hit_metal_bang.mp3 +0 -0
  9. package/assets/audio/ice_spear.mp3 +0 -0
  10. package/assets/audio/monster_died.mp3 +0 -0
  11. package/assets/audio/slash.mp3 +0 -0
  12. package/assets/audio/smite.mp3 +0 -0
  13. package/assets/audio/spells_cast.mp3 +0 -0
  14. package/assets/audio/ui_click.wav +0 -0
  15. package/assets/audio/walk.mp3 +0 -0
  16. package/assets/catalog.json +390 -0
  17. package/assets/characters/2dbasic.json +41 -0
  18. package/assets/characters/2dbasic.png +0 -0
  19. package/assets/characters/ghost.json +46 -0
  20. package/assets/characters/ghost.png +0 -0
  21. package/assets/characters/goblin.json +40 -0
  22. package/assets/characters/goblin.png +0 -0
  23. package/assets/characters/medieval-knight.json +41 -0
  24. package/assets/characters/medieval-knight.png +0 -0
  25. package/assets/effects/swoosh.png +0 -0
  26. package/assets/items/box.png +0 -0
  27. package/assets/items/buff_potion.png +0 -0
  28. package/assets/items/coin.png +0 -0
  29. package/assets/items/gem.png +0 -0
  30. package/assets/items/gold.png +0 -0
  31. package/assets/items/hp_potion.png +0 -0
  32. package/assets/items/locked_item_box.png +0 -0
  33. package/assets/items/map.png +0 -0
  34. package/assets/items/resurrection_potion.png +0 -0
  35. package/assets/items/super_box.png +0 -0
  36. package/assets/items/trap.png +0 -0
  37. package/assets/tiles/floor00.jpg +0 -0
  38. package/assets/tiles/minecraft-tiles.png +0 -0
  39. package/assets/tiles/wall00.jpg +0 -0
  40. package/assets/vegetation/ash_color.png +0 -0
  41. package/assets/vegetation/aspen_color.png +0 -0
  42. package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
  43. package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
  44. package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
  45. package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
  46. package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
  47. package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
  48. package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
  49. package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
  50. package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
  51. package/assets/vegetation/ground/dirt_color.jpg +0 -0
  52. package/assets/vegetation/ground/dirt_normal.jpg +0 -0
  53. package/assets/vegetation/ground/grass.jpg +0 -0
  54. package/assets/vegetation/oak_color.png +0 -0
  55. package/assets/vegetation/pine_color.png +0 -0
  56. package/bin/incanto-assets.mjs +107 -0
  57. package/bin/incanto-check.mjs +107 -0
  58. package/bin/incanto-editor.mjs +343 -0
  59. package/bin/incanto-env.mjs +144 -0
  60. package/bin/incanto-model.mjs +296 -0
  61. package/bin/incanto-play.mjs +219 -0
  62. package/bin/incanto-skills.mjs +71 -0
  63. package/dist/2d.d.ts +642 -0
  64. package/dist/2d.js +44 -0
  65. package/dist/3d.d.ts +1860 -0
  66. package/dist/3d.js +5 -0
  67. package/dist/agent8-DzU2fFyH.js +129 -0
  68. package/dist/audio-player-DqUR3XFs.d.ts +110 -0
  69. package/dist/behavior-BAQq7HGM.d.ts +851 -0
  70. package/dist/create-game-BdjpTHrW.js +1725 -0
  71. package/dist/create-game-CZHROKcT.js +527 -0
  72. package/dist/debug-draw-CZmOYjL2.js +13 -0
  73. package/dist/debug.d.ts +66 -0
  74. package/dist/debug.js +658 -0
  75. package/dist/duplicate-DP2WPYom.js +22 -0
  76. package/dist/env.d.ts +430 -0
  77. package/dist/env.js +3152 -0
  78. package/dist/errors-BMFaY68Q.d.ts +33 -0
  79. package/dist/errors-BpWbnbb_.js +13 -0
  80. package/dist/gameplay-Ccruc3Wd.js +1501 -0
  81. package/dist/gameplay.d.ts +543 -0
  82. package/dist/gameplay.js +2 -0
  83. package/dist/heightmap-CroQPEER.js +185 -0
  84. package/dist/index.d.ts +305 -0
  85. package/dist/index.js +62 -0
  86. package/dist/json-BLk7H2Qa.js +30 -0
  87. package/dist/loader-CGs_G-r0.js +919 -0
  88. package/dist/loader-Mo0KghCv.d.ts +41 -0
  89. package/dist/net.d.ts +427 -0
  90. package/dist/net.js +772 -0
  91. package/dist/noise-CGUMx44x.js +82 -0
  92. package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
  93. package/dist/particle-sim-DYuSUxvK.js +1319 -0
  94. package/dist/physics-2d-KuMWPTf6.js +288 -0
  95. package/dist/physics-3d-Dl67vOLT.js +434 -0
  96. package/dist/react.d.ts +65 -0
  97. package/dist/react.js +209 -0
  98. package/dist/register-BuUV1_KB.js +561 -0
  99. package/dist/register-CNlYAS1_.js +10634 -0
  100. package/dist/register-DPEV9_9t.js +851 -0
  101. package/dist/register-Dasmnurl.js +374 -0
  102. package/dist/registry-BVJ2HbCn.js +132 -0
  103. package/dist/rng-DP-SR7eg.js +38 -0
  104. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  105. package/dist/schema-CcoWb32N.d.ts +104 -0
  106. package/dist/test.d.ts +158 -0
  107. package/dist/test.js +275 -0
  108. package/dist/touch-031PxtCR.js +208 -0
  109. package/dist/vite.d.ts +26 -0
  110. package/dist/vite.js +57 -0
  111. package/editor/assets/GameServer-C56iOUgF.js +1 -0
  112. package/editor/assets/agent8-Bp7QFI7v.js +1 -0
  113. package/editor/assets/index-DF3tMeKJ.css +1 -0
  114. package/editor/assets/index-Dl2pjA8e.js +7365 -0
  115. package/editor/assets/rapier-CEuLKeCu.js +1 -0
  116. package/editor/assets/rapier-DE6a0vmv.js +1 -0
  117. package/editor/index.html +169 -0
  118. package/package.json +97 -0
  119. package/schemas/scene.schema.json +4254 -0
  120. package/skills/README.md +9 -0
  121. package/skills/incanto-3d-character.md +229 -0
  122. package/skills/incanto-3d-models.md +151 -0
  123. package/skills/incanto-assets.md +118 -0
  124. package/skills/incanto-audio.md +309 -0
  125. package/skills/incanto-behaviors-and-scripts.md +169 -0
  126. package/skills/incanto-building-2d-games.md +242 -0
  127. package/skills/incanto-building-3d-games.md +245 -0
  128. package/skills/incanto-editor.md +163 -0
  129. package/skills/incanto-environment.md +743 -0
  130. package/skills/incanto-gameplay-behaviors.md +707 -0
  131. package/skills/incanto-multiplayer.md +264 -0
  132. package/skills/incanto-node-reference.md +797 -0
  133. package/skills/incanto-physics-and-input.md +164 -0
  134. package/skills/incanto-scene-json-authoring.md +325 -0
  135. package/skills/incanto-verifying-your-game.md +191 -0
  136. package/skills/incanto-web-integration.md +96 -0
  137. package/templates/agent8-server.js +84 -0
  138. package/templates/agent8-server.ts +138 -0
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-editor — serve the scene-composer page, rendering with the INSTALLED
4
+ * engine version (the editor bundle ships in this package).
5
+ *
6
+ * npx incanto-editor # PROJECT mode: discovers *.scene.json
7
+ * npx incanto-editor src/game.scene.json # SINGLE mode: one fixed file
8
+ * npx incanto-editor level.scene.json --output build/level.scene.json --port 5179 --host 0.0.0.0
9
+ *
10
+ * API (the page can only address *.scene.json files under the launch directory):
11
+ * GET /api/meta → { version, mode, root, input?, output? }
12
+ * GET /api/scenes → [{ rel, abs }] (project mode)
13
+ * POST /api/scenes → { path } creates a minimal scene (project mode)
14
+ * GET /api/scene[?file=] → scene JSON (single mode ignores ?file=)
15
+ * PUT /api/scene[?file=] → validates the scene shape, writes the file
16
+ *
17
+ * Security: on loopback binds, the Host header must be loopback (defeats DNS
18
+ * rebinding); writes additionally require Origin (when present) to match the
19
+ * request host. Bodies are capped at 8MB. Addressable paths are root-bounded
20
+ * and must end in .scene.json.
21
+ */
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { createServer } from 'node:http';
24
+ import { basename, dirname, extname, join, normalize, relative, resolve, sep } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
28
+ const EDITOR_DIR = join(PKG_ROOT, 'editor');
29
+
30
+ // Biome-compatible JSON layout — same algorithm as the editor's
31
+ // src/format-json.ts (inline when the one-line form fits 80 cols).
32
+ function formatSceneJson(value) {
33
+ const render = (v, depth) => {
34
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
35
+ const inline = inlineForm(v);
36
+ if (depth * 2 + inline.length <= 80) return inline;
37
+ const pad = ' '.repeat(depth + 1);
38
+ const close = ' '.repeat(depth);
39
+ if (Array.isArray(v)) {
40
+ if (v.length === 0) return '[]';
41
+ return `[\n${v.map((x) => pad + render(x, depth + 1)).join(',\n')}\n${close}]`;
42
+ }
43
+ const entries = Object.entries(v);
44
+ if (entries.length === 0) return '{}';
45
+ const items = entries.map(([k, x]) => `${pad}${JSON.stringify(k)}: ${render(x, depth + 1)}`);
46
+ return `{\n${items.join(',\n')}\n${close}}`;
47
+ };
48
+ const inlineForm = (v) => {
49
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
50
+ if (Array.isArray(v)) return v.length === 0 ? '[]' : `[${v.map(inlineForm).join(', ')}]`;
51
+ const entries = Object.entries(v);
52
+ if (entries.length === 0) return '{}';
53
+ return `{ ${entries.map(([k, x]) => `${JSON.stringify(k)}: ${inlineForm(x)}`).join(', ')} }`;
54
+ };
55
+ return `${render(value, 0)}\n`;
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const args = { input: undefined, output: undefined, port: 5179, host: '127.0.0.1' };
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const a = argv[i];
62
+ if (a === '--output') args.output = argv[++i];
63
+ else if (a === '--port') args.port = Number(argv[++i]);
64
+ else if (a === '--host') args.host = argv[++i];
65
+ else if (a === '--help' || a === '-h') args.help = true;
66
+ else if (!a.startsWith('-') && !args.input) args.input = a;
67
+ else {
68
+ console.error(`unknown argument: ${a}`);
69
+ args.error = true;
70
+ }
71
+ }
72
+ return args;
73
+ }
74
+
75
+ const args = parseArgs(process.argv.slice(2));
76
+ if (args.help || args.error || Number.isNaN(args.port) || (args.output && !args.input)) {
77
+ console.log(`Usage: npx incanto-editor [scene.json] [--output <path>] [--port 5179] [--host 127.0.0.1]
78
+
79
+ Without a file: PROJECT mode — discovers every *.scene.json under the current
80
+ directory (a picker opens; a single scene auto-opens; new scenes can be created).
81
+ With a file: SINGLE mode — that file only; saving writes to --output (default:
82
+ the input file). --output requires an input file. Use --host 0.0.0.0 inside containers.`);
83
+ process.exit(args.help && !args.error ? 0 : 1);
84
+ }
85
+
86
+ const MODE = args.input ? 'single' : 'project';
87
+ const ROOT = resolve('.');
88
+ const INPUT = args.input ? resolve(args.input) : null;
89
+ const OUTPUT = args.input ? resolve(args.output ?? args.input) : null;
90
+ const VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
91
+
92
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage']);
93
+
94
+ function discoverScenes() {
95
+ const found = [];
96
+ const walk = (dir, depth) => {
97
+ if (depth > 6) return;
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(dir, { withFileTypes: true });
101
+ } catch {
102
+ return;
103
+ }
104
+ for (const e of entries) {
105
+ if (e.isDirectory()) {
106
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), depth + 1);
107
+ } else if (e.name.endsWith('.scene.json')) {
108
+ const abs = join(dir, e.name);
109
+ found.push({ rel: relative(ROOT, abs), abs });
110
+ }
111
+ }
112
+ };
113
+ walk(ROOT, 0);
114
+ return found.sort((a, b) => a.rel.localeCompare(b.rel));
115
+ }
116
+
117
+ /** Root-bounded, *.scene.json-only — the page can never address anything else. */
118
+ function resolveSceneFile(rel) {
119
+ if (typeof rel !== 'string' || !rel.endsWith('.scene.json')) return null;
120
+ const abs = normalize(resolve(ROOT, rel));
121
+ if (abs !== ROOT && !abs.startsWith(ROOT + sep)) return null;
122
+ return abs;
123
+ }
124
+
125
+ function targetFor(url) {
126
+ if (MODE === 'single') return { input: INPUT, output: OUTPUT };
127
+ const abs = resolveSceneFile(url.searchParams.get('file'));
128
+ return abs ? { input: abs, output: abs } : null;
129
+ }
130
+
131
+ const MIME = {
132
+ '.html': 'text/html; charset=utf-8',
133
+ '.js': 'text/javascript; charset=utf-8',
134
+ '.css': 'text/css; charset=utf-8',
135
+ '.json': 'application/json; charset=utf-8',
136
+ '.svg': 'image/svg+xml',
137
+ '.png': 'image/png',
138
+ '.wasm': 'application/wasm',
139
+ };
140
+
141
+ function send(res, status, body, type = 'application/json; charset=utf-8') {
142
+ res.writeHead(status, { 'content-type': type });
143
+ res.end(body);
144
+ }
145
+
146
+ const LOOPBACK_BIND = args.host === '127.0.0.1' || args.host === 'localhost' || args.host === '::1';
147
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
148
+ const MAX_BODY = 8 * 1024 * 1024;
149
+
150
+ function hostAllowed(req) {
151
+ // Loopback bind: the Host header must be loopback too — a DNS-rebound page
152
+ // (attacker.com → 127.0.0.1) arrives with Host: attacker.com and is rejected.
153
+ if (!LOOPBACK_BIND) return true; // non-loopback (containers): host names vary by network
154
+ const host = (req.headers.host ?? '').replace(/:\d+$/, '');
155
+ return LOOPBACK_HOSTS.has(host);
156
+ }
157
+
158
+ function originAllowed(req) {
159
+ // Same-origin assertion for state-changing requests: when a browser sends an
160
+ // Origin header it must match the host we were addressed as.
161
+ const origin = req.headers.origin;
162
+ if (!origin) return true;
163
+ try {
164
+ return new URL(origin).host === (req.headers.host ?? '');
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ function isSceneShaped(json) {
171
+ return (
172
+ json !== null &&
173
+ typeof json === 'object' &&
174
+ json.format === 1 &&
175
+ json.type === 'scene' &&
176
+ typeof json.name === 'string' &&
177
+ json.root !== null &&
178
+ typeof json.root === 'object'
179
+ );
180
+ }
181
+
182
+ function readBody(req, res, onJson) {
183
+ const chunks = [];
184
+ let received = 0;
185
+ req.on('data', (c) => {
186
+ received += c.length;
187
+ if (received > MAX_BODY) {
188
+ send(res, 413, JSON.stringify({ error: 'body too large' }));
189
+ req.destroy();
190
+ return;
191
+ }
192
+ chunks.push(c);
193
+ });
194
+ req.on('end', () => {
195
+ if (res.writableEnded) return;
196
+ try {
197
+ const raw = Buffer.concat(chunks).toString('utf-8');
198
+ onJson(JSON.parse(raw), raw);
199
+ } catch (error) {
200
+ send(res, 400, JSON.stringify({ error: String(error.message ?? error) }));
201
+ }
202
+ });
203
+ }
204
+
205
+ const server = createServer((req, res) => {
206
+ const url = new URL(req.url ?? '/', 'http://localhost');
207
+
208
+ if (!hostAllowed(req)) {
209
+ return send(res, 403, JSON.stringify({ error: 'forbidden host' }));
210
+ }
211
+
212
+ if (url.pathname === '/api/meta') {
213
+ return send(
214
+ res,
215
+ 200,
216
+ JSON.stringify({ version: VERSION, mode: MODE, root: ROOT, input: INPUT, output: OUTPUT }),
217
+ );
218
+ }
219
+
220
+ if (url.pathname === '/api/scenes') {
221
+ if (MODE !== 'project') {
222
+ return send(res, 400, JSON.stringify({ error: 'scene listing is project-mode only' }));
223
+ }
224
+ if (req.method === 'GET') {
225
+ return send(res, 200, JSON.stringify(discoverScenes()));
226
+ }
227
+ if (req.method === 'POST') {
228
+ if (!originAllowed(req)) {
229
+ return send(res, 403, JSON.stringify({ error: 'cross-origin write rejected' }));
230
+ }
231
+ readBody(req, res, (body) => {
232
+ const abs = resolveSceneFile(body?.path);
233
+ if (!abs) {
234
+ return send(
235
+ res,
236
+ 400,
237
+ JSON.stringify({ error: 'path must be a *.scene.json inside the project' }),
238
+ );
239
+ }
240
+ if (existsSync(abs)) {
241
+ return send(res, 409, JSON.stringify({ error: `already exists: ${body.path}` }));
242
+ }
243
+ const name = basename(abs).replace(/\.scene\.json$/, '') || 'Scene';
244
+ const scene = {
245
+ format: 1,
246
+ type: 'scene',
247
+ dimension: '2d',
248
+ name,
249
+ root: { name, type: 'Node2D' },
250
+ };
251
+ try {
252
+ mkdirSync(dirname(abs), { recursive: true });
253
+ writeFileSync(abs, formatSceneJson(scene));
254
+ return send(res, 201, JSON.stringify({ rel: relative(ROOT, abs), abs }));
255
+ } catch (error) {
256
+ return send(res, 400, JSON.stringify({ error: String(error.message ?? error) }));
257
+ }
258
+ });
259
+ return;
260
+ }
261
+ return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
262
+ }
263
+
264
+ if (url.pathname === '/api/scene') {
265
+ const target = targetFor(url);
266
+ if (!target) {
267
+ return send(
268
+ res,
269
+ 400,
270
+ JSON.stringify({ error: '?file= must be a *.scene.json inside the project' }),
271
+ );
272
+ }
273
+ if (req.method === 'GET') {
274
+ try {
275
+ const text = readFileSync(target.input, 'utf-8');
276
+ JSON.parse(text); // must at least be JSON before the page gets it
277
+ return send(res, 200, text);
278
+ } catch (error) {
279
+ return send(res, 422, JSON.stringify({ error: String(error.message ?? error) }));
280
+ }
281
+ }
282
+ if (req.method === 'PUT') {
283
+ if (!originAllowed(req)) {
284
+ return send(res, 403, JSON.stringify({ error: 'cross-origin write rejected' }));
285
+ }
286
+ readBody(req, res, (json, raw) => {
287
+ if (!isSceneShaped(json)) {
288
+ return send(
289
+ res,
290
+ 422,
291
+ JSON.stringify({
292
+ error:
293
+ 'not an incanto scene — required: format: 1, type: "scene", name: string, root: object. The file was NOT written.',
294
+ }),
295
+ );
296
+ }
297
+ try {
298
+ // Write the client's bytes verbatim — the editor formats scenes
299
+ // biome-compatibly; re-stringifying here would undo that.
300
+ writeFileSync(target.output, raw.endsWith('\n') ? raw : `${raw}\n`);
301
+ return send(res, 200, JSON.stringify({ ok: true, output: target.output }));
302
+ } catch (error) {
303
+ return send(res, 400, JSON.stringify({ error: String(error.message ?? error) }));
304
+ }
305
+ });
306
+ return;
307
+ }
308
+ return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
309
+ }
310
+
311
+ // static editor assets (path-traversal guarded)
312
+ const rel = url.pathname === '/' ? '/index.html' : url.pathname;
313
+ const file = normalize(join(EDITOR_DIR, rel));
314
+ if (file !== EDITOR_DIR && !file.startsWith(EDITOR_DIR + sep)) {
315
+ return send(res, 403, JSON.stringify({ error: 'forbidden' }));
316
+ }
317
+ try {
318
+ const body = readFileSync(file);
319
+ return send(res, 200, body, MIME[extname(file)] ?? 'application/octet-stream');
320
+ } catch {
321
+ return send(res, 404, JSON.stringify({ error: `not found: ${rel}` }));
322
+ }
323
+ });
324
+
325
+ server.listen(args.port, args.host, () => {
326
+ console.log(`incanto-editor v${VERSION}`);
327
+ if (!LOOPBACK_BIND) {
328
+ console.warn(
329
+ ` WARNING: bound to ${args.host} — anyone who can reach this port can read/write scene files. Use only on trusted networks (e.g. inside a container).`,
330
+ );
331
+ }
332
+ if (MODE === 'single') {
333
+ console.log(` scene: ${INPUT}${OUTPUT === INPUT ? '' : `\n output: ${OUTPUT}`}`);
334
+ } else {
335
+ const scenes = discoverScenes();
336
+ console.log(
337
+ ` project: ${ROOT} (${scenes.length} scene file${scenes.length === 1 ? '' : 's'})`,
338
+ );
339
+ }
340
+ console.log(
341
+ ` url: http://${args.host === '0.0.0.0' ? 'localhost' : args.host}:${args.port}/`,
342
+ );
343
+ });
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-env — deterministic environment generators on the command line.
4
+ * Emits scene-insertable NodeJson; the same --seed always emits the same
5
+ * JSON (keep the seed in your scene notes so levels are reproducible).
6
+ *
7
+ * Subcommands and flags come straight from the GENERATORS metadata in the
8
+ * built library, so `--list` and the params can never drift from the code:
9
+ *
10
+ * npx incanto-env --list every generator + params
11
+ * npx incanto-env <generator> --seed 42 [--<param> <value> …] [--json]
12
+ * npx incanto-env terrain --seed 42 --theme meadow
13
+ * … --into src/game.scene.json [--at Root/Level] insert instead of print
14
+ *
15
+ * generateScatter is library-only (`import {generateScatter} from
16
+ * 'incanto/env'`) — its item templates don't fit CLI flags.
17
+ */
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
23
+
24
+ // ---- the catalog (via the BUILT library — exactly what consumers run) ---------------
25
+ const env = await import(pathToFileURL(join(PKG, 'dist', 'env.js')).href);
26
+
27
+ const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
28
+
29
+ const [command, ...rest] = process.argv.slice(2);
30
+
31
+ if (!command || command === '--help' || command === '-h' || command === '--list') {
32
+ const rows = Object.entries(env.GENERATORS).map(([name, meta]) => {
33
+ const params = Object.entries(meta.params)
34
+ .map(([param, def]) =>
35
+ def.type === 'boolean'
36
+ ? `[--${kebab(param)}]`
37
+ : `[--${kebab(param)} ${JSON.stringify(def.default)}]`,
38
+ )
39
+ .join(' ');
40
+ return ` ${name.padEnd(12)} ${meta.dimension} ${meta.description}\n --seed <n> ${params}`;
41
+ });
42
+ console.log(`Usage:
43
+ incanto-env <generator> --seed <n> [--<param> <value> …] [--json]
44
+ incanto-env <generator> --seed <n> --into <scene.json> [--at Root/Level]
45
+ incanto-env --list
46
+
47
+ --seed is REQUIRED — generators are deterministic, the same seed always emits
48
+ the same JSON. scatter is library-only (needs item templates).
49
+
50
+ ${rows.join('\n')}`);
51
+ process.exit(command ? 0 : 1);
52
+ }
53
+
54
+ const meta = env.GENERATORS[command];
55
+ if (!meta) {
56
+ console.error(
57
+ `unknown command '${command}'. Available: [${Object.keys(env.GENERATORS).join(', ')}] — the old meadow/forest/island/rocks/clouds generators became themes (e.g. terrain --theme meadow); scatter is library-only (needs item templates).`,
58
+ );
59
+ process.exit(1);
60
+ }
61
+
62
+ // ---- flags (generic, metadata-driven) ------------------------------------------------
63
+ const paramByFlag = new Map();
64
+ const BOOLEAN_FLAGS = new Set(['--json']);
65
+ for (const [param, def] of Object.entries(meta.params)) {
66
+ const flag = `--${kebab(param)}`;
67
+ paramByFlag.set(flag, { param, def });
68
+ if (def.type === 'boolean') BOOLEAN_FLAGS.add(flag);
69
+ }
70
+
71
+ const flags = new Map();
72
+ for (let i = 0; i < rest.length; i++) {
73
+ const arg = rest[i];
74
+ if (!arg.startsWith('--')) {
75
+ console.error(`unexpected argument '${arg}' — flags look like --seed 42`);
76
+ process.exit(1);
77
+ }
78
+ if (BOOLEAN_FLAGS.has(arg)) {
79
+ flags.set(arg, true);
80
+ } else {
81
+ flags.set(arg, rest[++i]);
82
+ }
83
+ }
84
+
85
+ function num(name) {
86
+ if (!flags.has(name)) return undefined;
87
+ const value = Number(flags.get(name));
88
+ if (!Number.isFinite(value)) {
89
+ console.error(`${name} must be a number, got '${flags.get(name)}'`);
90
+ process.exit(1);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ const seed = num('--seed');
96
+ if (seed === undefined) {
97
+ console.error(
98
+ '--seed is required: generators are deterministic — the same seed always emits the same JSON, ' +
99
+ 'so a level is reproducible from its seed alone. Pick any integer and keep it in your scene notes.',
100
+ );
101
+ process.exit(1);
102
+ }
103
+
104
+ // ---- generate ------------------------------------------------------------------------
105
+ const opts = { seed };
106
+ for (const [flag, value] of flags) {
107
+ if (flag === '--seed' || flag === '--json' || flag === '--into' || flag === '--at') continue;
108
+ const known = paramByFlag.get(flag);
109
+ if (!known) {
110
+ console.error(
111
+ `unknown flag '${flag}' for ${command}. Valid: [${[...paramByFlag.keys()].join(', ')}]`,
112
+ );
113
+ process.exit(1);
114
+ }
115
+ opts[known.param] = known.def.type === 'number' ? num(flag) : value;
116
+ }
117
+
118
+ let node;
119
+ try {
120
+ node = env.runGenerator(command, opts);
121
+ } catch (e) {
122
+ console.error(e?.message ?? String(e));
123
+ process.exit(1);
124
+ }
125
+
126
+ const countNodes = (n) => 1 + (n.children ?? []).reduce((sum, c) => sum + countNodes(c), 0);
127
+
128
+ if (flags.has('--into')) {
129
+ const file = resolve(flags.get('--into'));
130
+ const at = flags.get('--at');
131
+ const scene = JSON.parse(readFileSync(file, 'utf-8'));
132
+ const out = env.insertIntoScene(scene, node, at);
133
+ writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
134
+ console.log(
135
+ `inserted '${node.name}' (${countNodes(node)} node(s)) into ${file}${at ? ` at '${at}'` : ''} — seed ${seed}`,
136
+ );
137
+ } else {
138
+ console.log(JSON.stringify(node, null, 2));
139
+ if (!flags.has('--json')) {
140
+ console.error(
141
+ `${command}: ${countNodes(node)} node(s), seed ${seed} — pipe to a file or rerun with --into <scene.json>`,
142
+ );
143
+ }
144
+ }