@downdraft/cli 0.0.0 → 0.1.1

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 (84) hide show
  1. package/README.md +11 -0
  2. package/package.json +36 -3
  3. package/scripts/default-icon.png +0 -0
  4. package/scripts/package-desktop.mjs +931 -0
  5. package/scripts/package-mobile.mjs +822 -0
  6. package/scripts/package-native.mjs +638 -0
  7. package/src/args.ts +333 -0
  8. package/src/assets.ts +523 -0
  9. package/src/blob-store-s3.ts +145 -0
  10. package/src/build-games.ts +45 -0
  11. package/src/build.ts +40 -0
  12. package/src/bun-env.d.ts +6 -0
  13. package/src/debug.ts +56 -0
  14. package/src/dev.ts +231 -0
  15. package/src/dist.ts +36 -0
  16. package/src/export.ts +38 -0
  17. package/src/index.ts +131 -0
  18. package/src/list-games.ts +37 -0
  19. package/src/mcp.ts +320 -0
  20. package/src/new.ts +63 -0
  21. package/src/paths.ts +101 -0
  22. package/src/plugin-command.ts +214 -0
  23. package/src/process-utils.ts +179 -0
  24. package/src/release.ts +432 -0
  25. package/src/scaffold-plugin.ts +280 -0
  26. package/src/scaffold.ts +207 -0
  27. package/src/test.ts +187 -0
  28. package/src/usage.ts +256 -0
  29. package/templates/_shared/ai-companion/devin-config.eta +11 -0
  30. package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
  31. package/templates/_shared/ai-companion/logging-rule.eta +3 -0
  32. package/templates/_shared/ai-companion/loops-rule.eta +3 -0
  33. package/templates/_shared/partials/config-json-fields.eta +3 -0
  34. package/templates/_shared/partials/dev-deps.eta +8 -0
  35. package/templates/_shared/partials/package-json-fields.eta +12 -0
  36. package/templates/_shared/partials/tsconfig.eta +16 -0
  37. package/templates/_shared/partials/vscode-extensions.eta +11 -0
  38. package/templates/_shared/partials/vscode-settings.eta +29 -0
  39. package/templates/_shared/partials/vscode-tasks.eta +93 -0
  40. package/templates/full/.gitignore.eta +5 -0
  41. package/templates/full/.vscode/extensions.json.eta +1 -0
  42. package/templates/full/.vscode/settings.json.eta +1 -0
  43. package/templates/full/.vscode/tasks.json.eta +1 -0
  44. package/templates/full/README.md.eta +45 -0
  45. package/templates/full/assets/shaders/.eta +0 -0
  46. package/templates/full/downdraft.config.json.eta +10 -0
  47. package/templates/full/package.json.eta +9 -0
  48. package/templates/full/src/entities/.eta +0 -0
  49. package/templates/full/src/game-module.ts.eta +30 -0
  50. package/templates/full/src/native-entry.ts.eta +14 -0
  51. package/templates/full/src/sim-host.ts.eta +19 -0
  52. package/templates/full/src/sim-worker.ts.eta +99 -0
  53. package/templates/full/src/systems/.eta +0 -0
  54. package/templates/full/tsconfig.json.eta +1 -0
  55. package/templates/gamemodule/README.md.eta +32 -0
  56. package/templates/gamemodule/downdraft.config.json.eta +3 -0
  57. package/templates/gamemodule/package.json.eta +9 -0
  58. package/templates/gamemodule/src/game-module.ts.eta +63 -0
  59. package/templates/gamemodule/src/native-entry.ts.eta +14 -0
  60. package/templates/gamemodule/src/sim-host.ts.eta +25 -0
  61. package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
  62. package/templates/gamemodule/tsconfig.json.eta +1 -0
  63. package/templates/minimal/.gitignore.eta +5 -0
  64. package/templates/minimal/.vscode/extensions.json.eta +1 -0
  65. package/templates/minimal/.vscode/settings.json.eta +1 -0
  66. package/templates/minimal/.vscode/tasks.json.eta +1 -0
  67. package/templates/minimal/README.md.eta +32 -0
  68. package/templates/minimal/downdraft.config.json.eta +3 -0
  69. package/templates/minimal/package.json.eta +9 -0
  70. package/templates/minimal/src/game-module.ts.eta +44 -0
  71. package/templates/minimal/src/native-entry.ts.eta +14 -0
  72. package/templates/minimal/tsconfig.json.eta +1 -0
  73. package/templates/physics/.gitignore.eta +5 -0
  74. package/templates/physics/.vscode/extensions.json.eta +1 -0
  75. package/templates/physics/.vscode/settings.json.eta +1 -0
  76. package/templates/physics/.vscode/tasks.json.eta +1 -0
  77. package/templates/physics/README.md.eta +36 -0
  78. package/templates/physics/downdraft.config.json.eta +6 -0
  79. package/templates/physics/package.json.eta +9 -0
  80. package/templates/physics/src/game-module.ts.eta +29 -0
  81. package/templates/physics/src/native-entry.ts.eta +14 -0
  82. package/templates/physics/src/sim-host.ts.eta +19 -0
  83. package/templates/physics/src/sim-worker.ts.eta +155 -0
  84. package/templates/physics/tsconfig.json.eta +1 -0
@@ -0,0 +1,280 @@
1
+ // ============================================================================
2
+ // Plugin scaffold — generates a new plugin directory from a template.
3
+ //
4
+ // Usage: `dd plugin new <name> --format <format> --game <game> [options]`
5
+ // `dd mod new <name> --game <game> [options]`
6
+ //
7
+ // Generates:
8
+ // - mod.json (manifest, when --mod is used) or plugin.json (legacy)
9
+ // - package.json
10
+ // - src/index.ts (for worker-js/quickjs) or src/index.js (for quickjs)
11
+ // - README.md
12
+ // ============================================================================
13
+
14
+ import type { PluginFormat, PluginThread, PluginTier } from "@downdraft/engine";
15
+ import { createLogger } from "@downdraft/engine";
16
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
17
+ import { join, resolve } from "path";
18
+
19
+ const log = createLogger();
20
+
21
+ export interface PluginScaffoldOptions {
22
+ /** Plugin id (kebab-case, e.g. "my-cool-plugin"). */
23
+ id: string;
24
+ /** Display name. */
25
+ name: string;
26
+ /** Plugin format. */
27
+ format: PluginFormat;
28
+ /** Target game appId (e.g. "downdraft-overburden"). */
29
+ game: string;
30
+ /** Plugin version. */
31
+ version: string;
32
+ /** Author. */
33
+ author?: string;
34
+ /** Description. */
35
+ description?: string;
36
+ /** Target directory (the plugin will be created as a subdirectory). */
37
+ targetDir: string;
38
+ /** Overwrite if directory exists. */
39
+ force: boolean;
40
+ /** Generate a mod.json (pack format) instead of a legacy plugin.json. */
41
+ mod?: boolean;
42
+ /** Mod extension types to include (only with --mod). */
43
+ extensions?: Array<"assets" | "maps" | "physics" | "shader-postfx" | "shader-material">;
44
+ }
45
+
46
+ const FORMAT_DEFAULTS: Record<PluginFormat, { tier: PluginTier; thread: PluginThread; permissions: string[] }> = {
47
+ "worker-js": { tier: "native", thread: "own-worker", permissions: ["ecs", "events", "state", "tick", "log"] },
48
+ quickjs: { tier: "script", thread: "renderer", permissions: ["events", "state", "tick", "log"] },
49
+ wasm: { tier: "native", thread: "own-worker", permissions: ["events", "state", "log"] },
50
+ asset: { tier: "data", thread: "renderer", permissions: [] },
51
+ };
52
+
53
+ export async function scaffoldPlugin(opts: PluginScaffoldOptions): Promise<void> {
54
+ const defaults = FORMAT_DEFAULTS[opts.format];
55
+ const pluginDir = join(resolve(opts.targetDir), opts.id);
56
+
57
+ if (existsSync(pluginDir) && !opts.force) {
58
+ log.error("plugin", `Directory "${pluginDir}" already exists. Use --force to override.`);
59
+ process.exit(1);
60
+ }
61
+
62
+ mkdirSync(pluginDir, { recursive: true });
63
+ if (opts.format !== "asset" || (opts.mod && !opts.extensions?.includes("assets"))) {
64
+ mkdirSync(join(pluginDir, "src"), { recursive: true });
65
+ }
66
+
67
+ // ── Manifest: mod.json or plugin.json ──
68
+ const manifest: Record<string, unknown> = {
69
+ id: opts.id,
70
+ name: opts.name,
71
+ version: opts.version,
72
+ author: opts.author ?? "",
73
+ description: opts.description ?? "",
74
+ engineVersion: "^0.1.0",
75
+ game: opts.game,
76
+ format: opts.format,
77
+ tier: defaults.tier,
78
+ thread: defaults.thread,
79
+ permissions: defaults.permissions,
80
+ provides: [],
81
+ requires: [],
82
+ dependencies: [],
83
+ };
84
+
85
+ if (opts.format !== "asset") {
86
+ manifest.entry = opts.format === "quickjs" ? "./src/index.js" : "./src/index.ts";
87
+ } else {
88
+ manifest.assets = { files: {}, textures: [], audio: [], meshes: [], data: [] };
89
+ }
90
+
91
+ if (opts.format === "quickjs") {
92
+ manifest.quickjs = { instructionBudget: 500000 };
93
+ }
94
+
95
+ // ── Mod.json extensions ──
96
+ if (opts.mod) {
97
+ const extensions: Record<string, unknown> = {};
98
+ if (opts.extensions?.includes("assets")) {
99
+ extensions.assets = [];
100
+ mkdirSync(join(pluginDir, "assets"), { recursive: true });
101
+ }
102
+ if (opts.extensions?.includes("maps")) {
103
+ extensions.maps = [];
104
+ mkdirSync(join(pluginDir, "maps"), { recursive: true });
105
+ }
106
+ if (opts.extensions?.includes("physics")) {
107
+ extensions.physics = [];
108
+ mkdirSync(join(pluginDir, "physics"), { recursive: true });
109
+ }
110
+ if (opts.extensions?.includes("shader-postfx") || opts.extensions?.includes("shader-material")) {
111
+ extensions.shaders = {};
112
+ if (opts.extensions?.includes("shader-postfx")) {
113
+ (extensions.shaders as Record<string, unknown>).postfx = [];
114
+ mkdirSync(join(pluginDir, "shaders"), { recursive: true });
115
+ }
116
+ if (opts.extensions?.includes("shader-material")) {
117
+ (extensions.shaders as Record<string, unknown>).materials = [];
118
+ if (!existsSync(join(pluginDir, "shaders"))) mkdirSync(join(pluginDir, "shaders"), { recursive: true });
119
+ }
120
+ }
121
+ if (Object.keys(extensions).length > 0) manifest.extensions = extensions;
122
+ // For a mod.json, use `logic` instead of top-level entry/permissions when
123
+ // the format is code-bearing.
124
+ if (opts.format !== "asset") {
125
+ manifest.logic = {
126
+ format: opts.format,
127
+ thread: defaults.thread,
128
+ entry: manifest.entry,
129
+ permissions: defaults.permissions,
130
+ };
131
+ }
132
+ writeFileSync(join(pluginDir, "mod.json"), JSON.stringify(manifest, null, 2) + "\n");
133
+ } else {
134
+ writeFileSync(join(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2) + "\n");
135
+ }
136
+
137
+ // ── package.json ──
138
+ const pkgName = `@${opts.game.replace(/^downdraft-/, "")}/plugin-${opts.id}`;
139
+ const pkg = {
140
+ name: pkgName,
141
+ version: opts.version,
142
+ description: opts.description ?? `${opts.name} plugin`,
143
+ author: opts.author ?? "",
144
+ private: true,
145
+ type: "module",
146
+ dependencies: { "@downdraft/engine": "workspace:*" },
147
+ };
148
+ writeFileSync(join(pluginDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
149
+
150
+ // ── Entry file ──
151
+ if (opts.format === "worker-js") {
152
+ writeFileSync(join(pluginDir, "src/index.ts"), WORKER_JS_TEMPLATE(opts));
153
+ } else if (opts.format === "quickjs") {
154
+ writeFileSync(join(pluginDir, "src/index.js"), QUICKJS_TEMPLATE(opts));
155
+ } else if (opts.format === "wasm") {
156
+ writeFileSync(join(pluginDir, "src/plugin.wat"), WAT_TEMPLATE(opts));
157
+ } else if (opts.format === "asset") {
158
+ mkdirSync(join(pluginDir, "assets"), { recursive: true });
159
+ writeFileSync(join(pluginDir, "assets/README.md"), "# Assets\nPlace your plugin assets here.\n");
160
+ }
161
+
162
+ // ── README.md ──
163
+ writeFileSync(join(pluginDir, "README.md"), README_TEMPLATE(opts));
164
+
165
+ log.info("plugin", `
166
+ ╔══════════════════════════════════════════╗
167
+ ║ DownDraft Engine — Plugin Scaffold ║
168
+ ╚══════════════════════════════════════════╝
169
+ `);
170
+ log.info("plugin", ` Plugin: ${opts.id}`);
171
+ log.info("plugin", ` Format: ${opts.format}`);
172
+ log.info("plugin", ` Tier: ${defaults.tier}`);
173
+ log.info("plugin", ` Thread: ${defaults.thread}`);
174
+ log.info("plugin", ` Game: ${opts.game}`);
175
+ log.info("plugin", ` Manifest: ${opts.mod ? "mod.json" : "plugin.json"}`);
176
+ if (opts.mod && opts.extensions?.length) {
177
+ log.info("plugin", ` Extensions: ${opts.extensions.join(", ")}`);
178
+ }
179
+ log.info("plugin", ` Target: ${pluginDir}`);
180
+ log.info("plugin", "");
181
+ log.info("plugin", `Next steps:`);
182
+ log.info("plugin", ` 1. Edit ${opts.mod ? "mod.json" : "plugin.json"} to declare provides/requires/permissions`);
183
+ log.info("plugin", ` 2. Implement the plugin entry in src/`);
184
+ log.info("plugin", ` 3. Add the plugin to your game's GameModule.plugins config`);
185
+ }
186
+
187
+ function WORKER_JS_TEMPLATE(opts: PluginScaffoldOptions): string {
188
+ return `import type { NativePluginContext, PluginEntry } from "@downdraft/engine";
189
+
190
+ const plugin: PluginEntry<NativePluginContext> = {
191
+ register(ctx: NativePluginContext) {
192
+ ctx.log.info("${opts.id} plugin registered.");
193
+ ctx.onDispose(() => {
194
+ ctx.log.debug("${opts.id} plugin unloading.");
195
+ });
196
+ },
197
+ };
198
+
199
+ export default plugin;
200
+ `;
201
+ }
202
+
203
+ function QUICKJS_TEMPLATE(opts: PluginScaffoldOptions): string {
204
+ return `function register(dd) {
205
+ dd.log.info("${opts.id} plugin registered.");
206
+
207
+ dd.onDispose(function() {
208
+ dd.log.debug("${opts.id} plugin unloading.");
209
+ });
210
+ }
211
+ `;
212
+ }
213
+
214
+ function WAT_TEMPLATE(opts: PluginScaffoldOptions): string {
215
+ return `;; ${opts.id} — WASM plugin (ABI v3)
216
+ ;; v3 adds host-call imports (spawn_prop, set_physics, apply_impulse, etc.)
217
+ ;; and the on_host_call_result export. v2 imports (log/state/events) are unchanged.
218
+ (module
219
+ (memory (export "memory") 1)
220
+ (global $heap (mut i32) (i32.const 1024))
221
+
222
+ (func (export "alloc") (param $size i32) (result i32)
223
+ (local $ptr i32)
224
+ (local.set $ptr (global.get $heap))
225
+ (global.set $heap (i32.add (global.get $heap) (local.get $size)))
226
+ (local.get $ptr)
227
+ )
228
+
229
+ (func (export "register")
230
+ ;; Initialize plugin state
231
+ )
232
+
233
+ (func (export "tick") (param $dt f32) (param $elapsed f32)
234
+ ;; Per-tick logic
235
+ )
236
+
237
+ (func (export "dispose")
238
+ ;; Cleanup
239
+ )
240
+
241
+ (func (export "on_event") (param $sub_id i32) (param $data_ptr i32) (param $data_len i32)
242
+ ;; Handle events
243
+ )
244
+
245
+ ;; v3: Called when a host-call completes. request_id matches the id returned
246
+ ;; by the host-call import. result_ptr/len point to a JSON result string
247
+ ;; (or 0,0 for void). error_ptr/len point to an error string (or 0,0 on success).
248
+ (func (export "on_host_call_result")
249
+ (param $req i32) (param $rptr i32) (param $rlen i32) (param $eptr i32) (param $elen i32)
250
+ ;; Handle host-call results
251
+ )
252
+ )
253
+ `;
254
+ }
255
+
256
+ function README_TEMPLATE(opts: PluginScaffoldOptions): string {
257
+ const manifestFile = opts.mod ? "mod.json" : "plugin.json";
258
+ const extList = opts.mod && opts.extensions?.length
259
+ ? `\n- **Extensions:** ${opts.extensions.join(", ")}`
260
+ : "";
261
+ return `# ${opts.name}
262
+
263
+ ${opts.description ?? "A user-authored plugin for the Downdraft Engine."}
264
+
265
+ - **Format:** \`${opts.format}\`
266
+ - **Tier:** \`${FORMAT_DEFAULTS[opts.format].tier}\`
267
+ - **Thread:** \`${FORMAT_DEFAULTS[opts.format].thread}\`
268
+ - **Game:** \`${opts.game}\`${extList}
269
+
270
+ ## Getting started
271
+
272
+ 1. Edit \`${manifestFile}\` to declare your \`provides\`, \`requires\`, and \`permissions\`.
273
+ 2. Implement the plugin entry in \`src/\`.
274
+ 3. Add the plugin to your game's \`GameModule.plugins\` config.
275
+
276
+ ## Built with
277
+
278
+ Generated by \`dd ${opts.mod ? "mod" : "plugin"} new\` — the Downdraft Engine CLI scaffold.
279
+ `;
280
+ }
@@ -0,0 +1,207 @@
1
+ import { confinePath, createLogger } from "@downdraft/engine";
2
+ import { Eta } from "eta";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
+ import { basename, dirname, join, relative, resolve } from "path";
5
+
6
+ const log = createLogger();
7
+
8
+ const TEMPLATES_DIR = resolve(import.meta.dir, "../templates");
9
+
10
+ export interface ScaffoldOptions {
11
+ template: string;
12
+ name: string;
13
+ description?: string;
14
+ author?: string;
15
+ version: string;
16
+ aiCompanion: boolean;
17
+ force: boolean;
18
+ }
19
+
20
+ export function listTemplates(): string[] {
21
+ if (!existsSync(TEMPLATES_DIR)) return [];
22
+ const entries = readdirSync(TEMPLATES_DIR);
23
+ const templates: string[] = [];
24
+ for (let i = 0; i < entries.length; i++) {
25
+ const entry = entries[i];
26
+ if (entry.startsWith("_")) continue;
27
+ const fullPath = join(TEMPLATES_DIR, entry);
28
+ if (statSync(fullPath).isDirectory()) {
29
+ templates.push(entry);
30
+ }
31
+ }
32
+ return templates;
33
+ }
34
+
35
+ function createEtaInstance(templateDir: string): Eta {
36
+ const eta = new Eta({
37
+ views: templateDir,
38
+ autoEscape: false,
39
+ useWith: true,
40
+ functionHeader: `
41
+ const kebabCase = (s) => s.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\\s+/g, '-').toLowerCase();
42
+ const pascalCase = (s) => s.replace(/(^|[-\\s])(.)/g, (_, __, c) => c.toUpperCase());
43
+ const camelCase = (s) => { const p = pascalCase(s); return p.charAt(0).toLowerCase() + p.slice(1); };
44
+ const titleCase = (s) => s.replace(/(^|[-\\s])(.)/g, (_, __, c) => ' ' + c.toUpperCase()).trim();
45
+ `,
46
+ });
47
+ return eta;
48
+ }
49
+
50
+ export async function scaffold(targetPath: string, opts: ScaffoldOptions): Promise<void> {
51
+ // Validate template name to prevent path traversal via the template parameter.
52
+ // Template names must be relative, non-traversing identifiers confined to the templates dir.
53
+ confinePath(TEMPLATES_DIR, opts.template);
54
+
55
+ const templateDir = join(TEMPLATES_DIR, opts.template);
56
+ if (!existsSync(templateDir)) {
57
+ log.error("scaffold", `Template "${opts.template}" not found at ${templateDir}`);
58
+ log.info("scaffold", `Available templates: ${listTemplates().join(", ")}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ // Validate the target path to prevent path traversal outside the current working directory.
63
+ const absTarget = confinePath(process.cwd(), targetPath);
64
+
65
+ if (existsSync(absTarget)) {
66
+ const entries = readdirSync(absTarget);
67
+ if (entries.length > 0 && !opts.force) {
68
+ log.error("scaffold", `Target directory "${absTarget}" is not empty. Use --force to override.`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ mkdirSync(absTarget, { recursive: true });
74
+
75
+ const eta = createEtaInstance(TEMPLATES_DIR);
76
+
77
+ const templateData = {
78
+ name: opts.name,
79
+ description: opts.description,
80
+ author: opts.author,
81
+ version: opts.version,
82
+ aiCompanion: opts.aiCompanion,
83
+ template: opts.template,
84
+ plugins: getPluginsForTemplate(opts.template),
85
+ };
86
+
87
+ log.info("scaffold", ` Template: ${opts.template}`);
88
+ log.info("scaffold", ` Name: ${opts.name}`);
89
+ log.info("scaffold", ` Version: ${opts.version}`);
90
+ if (opts.description) log.info("scaffold", ` Description: ${opts.description}`);
91
+ if (opts.author) log.info("scaffold", ` Author: ${opts.author}`);
92
+ log.info("scaffold", ` AI Companion: ${opts.aiCompanion ? "yes" : "no"}`);
93
+ log.info("scaffold", ` Target: ${absTarget}`);
94
+
95
+ const files = collectTemplateFiles(templateDir);
96
+ let createdCount = 0;
97
+
98
+ for (let i = 0; i < files.length; i++) {
99
+ const relPath = files[i];
100
+ const srcFile = join(templateDir, relPath);
101
+ const srcBasename = basename(relPath);
102
+ if (srcBasename === ".eta") {
103
+ const dirPath = join(absTarget, dirname(relPath));
104
+ mkdirSync(dirPath, { recursive: true });
105
+ continue;
106
+ }
107
+
108
+ const destRel = relPath.replace(/\.eta$/, "");
109
+ const destFile = join(absTarget, destRel);
110
+
111
+ mkdirSync(dirname(destFile), { recursive: true });
112
+
113
+ const content = readFileSync(srcFile, "utf-8");
114
+
115
+ let rendered: string;
116
+ try {
117
+ rendered = await eta.renderStringAsync(content, templateData);
118
+ } catch (err) {
119
+ log.error("scaffold", `Failed to render template: ${relPath}`);
120
+ throw err;
121
+ }
122
+
123
+ writeFileSync(destFile, rendered);
124
+ createdCount++;
125
+ }
126
+
127
+ if (opts.aiCompanion) {
128
+ const aiCompanionDir = join(TEMPLATES_DIR, "_shared", "ai-companion");
129
+ if (existsSync(aiCompanionDir)) {
130
+ const aiFiles = collectTemplateFiles(aiCompanionDir);
131
+ for (let i = 0; i < aiFiles.length; i++) {
132
+ const relPath = aiFiles[i];
133
+ const srcFile = join(aiCompanionDir, relPath);
134
+ const content = readFileSync(srcFile, "utf-8");
135
+
136
+ let rendered: string;
137
+ try {
138
+ rendered = await eta.renderStringAsync(content, templateData);
139
+ } catch (err) {
140
+ log.error("scaffold", `Failed to render AI companion template: ${relPath}`);
141
+ throw err;
142
+ }
143
+
144
+ const destMap: Record<string, string> = {
145
+ "devin-config.eta": ".devin/config.json",
146
+ "logging-rule.eta": ".devin/rules/logging.md",
147
+ "loops-rule.eta": ".devin/rules/loops.md",
148
+ "engine-prompt.eta": "engine-prompt.md",
149
+ };
150
+
151
+ const baseName = basename(relPath);
152
+ const destRel = destMap[baseName];
153
+ if (!destRel) continue;
154
+
155
+ const destFile = join(absTarget, destRel);
156
+ mkdirSync(dirname(destFile), { recursive: true });
157
+ writeFileSync(destFile, rendered);
158
+ createdCount++;
159
+ }
160
+ }
161
+ }
162
+
163
+ log.info("scaffold", `Created ${createdCount} files`);
164
+ log.info("scaffold", `Project scaffolded at: ${absTarget}`);
165
+ log.info("scaffold", ``);
166
+ log.info("scaffold", `Next steps:`);
167
+ log.info("scaffold", ` cd ${relative(process.cwd(), absTarget) || "."}`);
168
+ log.info("scaffold", ` bun install`);
169
+ log.info("scaffold", ` draft dev`);
170
+ }
171
+
172
+ function collectTemplateFiles(dir: string, base: string = dir): string[] {
173
+ const results: string[] = [];
174
+ if (!existsSync(dir)) return results;
175
+
176
+ const entries = readdirSync(dir);
177
+ for (let i = 0; i < entries.length; i++) {
178
+ const entry = entries[i];
179
+ const fullPath = join(dir, entry);
180
+ const stat = statSync(fullPath);
181
+ if (stat.isDirectory()) {
182
+ results.push(...collectTemplateFiles(fullPath, base));
183
+ } else {
184
+ const rel = relative(base, fullPath);
185
+ results.push(rel);
186
+ }
187
+ }
188
+ return results;
189
+ }
190
+
191
+ function getPluginsForTemplate(template: string): string[] {
192
+ switch (template) {
193
+ case "minimal":
194
+ return [];
195
+ case "physics":
196
+ return ["physics-rapier"];
197
+ case "full":
198
+ return ["physics-rapier", "water", "marching-cubes", "models", "devtools"];
199
+ case "gamemodule":
200
+ // GameModule-based scaffold — uses the declarative startGame() API.
201
+ // No plugins pre-wired; the developer adds EngineLibrary descriptors
202
+ // and feature plugins in their GameModule.
203
+ return [];
204
+ default:
205
+ return [];
206
+ }
207
+ }
package/src/test.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { createLogger } from "@downdraft/engine";
2
+ import { spawn } from "child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { createServer } from "node:net";
5
+ import { basename, resolve } from "node:path";
6
+ import { parseArgs, print, renderHelp } from "./args";
7
+ import { findMonorepoRoot, resolveGameDir } from "./paths";
8
+ import { getCommand } from "./usage";
9
+
10
+ const log = createLogger();
11
+
12
+ type Renderer = "gpu" | "cpu";
13
+
14
+ /** Grab an OS-assigned free TCP port so e2e runs don't collide with a
15
+ * running dev instance on the old pinned port (9976). */
16
+ function findFreePort(): Promise<number> {
17
+ return new Promise((resolve, reject) => {
18
+ const srv = createServer();
19
+ srv.listen(0, "127.0.0.1", () => {
20
+ const addr = srv.address();
21
+ const port = typeof addr === "object" && addr ? addr.port : 0;
22
+ srv.close(() => (port ? resolve(port) : reject(new Error("no port assigned"))));
23
+ });
24
+ srv.on("error", reject);
25
+ });
26
+ }
27
+
28
+ interface TestArgs {
29
+ game: string;
30
+ mcpPort: number;
31
+ spec: string | null;
32
+ renderer: Renderer;
33
+ deterministic: boolean;
34
+ headed: boolean;
35
+ verbose: boolean;
36
+ }
37
+
38
+ function parseTestArgs(args: string[]): TestArgs {
39
+ const entry = getCommand("test")!;
40
+ const parsed = parseArgs(args, entry.schema);
41
+ if (parsed.help) {
42
+ print(renderHelp(entry.usage, entry.schema));
43
+ process.exit(0);
44
+ }
45
+ const port = parsed.flags.port as number;
46
+ return {
47
+ game: parsed.flags.game as string,
48
+ mcpPort: port,
49
+ spec: (parsed.flags.spec as string) || null,
50
+ renderer: parsed.flags.renderer as Renderer,
51
+ deterministic: !(parsed.flags["no-deterministic"] as boolean),
52
+ headed: parsed.flags.headed as boolean,
53
+ verbose: parsed.flags.verbose as boolean,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Check whether xvfb-run is available on the system.
59
+ */
60
+ function hasXvfb(): boolean {
61
+ try {
62
+ return existsSync("/usr/bin/xvfb-run") || existsSync("/usr/local/bin/xvfb-run");
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+
69
+
70
+ export async function runTest(args: string[]): Promise<void> {
71
+ const opts = parseTestArgs(args);
72
+ const monorepoRoot = findMonorepoRoot();
73
+ const gameDir = resolveGameDir(opts.game || undefined);
74
+
75
+ if (!opts.game && gameDir) {
76
+ opts.game = basename(gameDir);
77
+ }
78
+ if (!opts.game) {
79
+ log.error("test", `No game specified and none could be inferred from "${process.cwd()}".`);
80
+ log.error("test", `Run "draft test --game=<name>" or pass --spec=<path>.`);
81
+ process.exit(1);
82
+ }
83
+
84
+ // Default spec: <monorepo>/tests/e2e/<game>-smoke.spec.ts, or the standalone
85
+ // game's own tests/e2e dir. When neither exists, require --spec.
86
+ let specPath = opts.spec ? resolve(opts.spec) : null;
87
+ if (!specPath) {
88
+ const candidates = [
89
+ monorepoRoot ? resolve(monorepoRoot, "tests", "e2e", `${opts.game}-smoke.spec.ts`) : null,
90
+ gameDir ? resolve(gameDir, "tests", "e2e", `${opts.game}-smoke.spec.ts`) : null,
91
+ ].filter((p): p is string => !!p);
92
+ specPath = candidates.find((p) => existsSync(p)) ?? candidates[0];
93
+ }
94
+ if (!specPath || !existsSync(specPath)) {
95
+ log.error("test", `No test spec found for game "${opts.game}".`);
96
+ log.error("test", `Looked in: tests/e2e/${opts.game}-smoke.spec.ts`);
97
+ log.error("test", `Pass --spec=<path> to specify a spec file.`);
98
+ process.exit(1);
99
+ }
100
+
101
+ if (!opts.mcpPort) {
102
+ opts.mcpPort = await findFreePort();
103
+ }
104
+
105
+ log.info("test", `
106
+ ╔══════════════════════════════════════════╗
107
+ ║ DownDraft Engine — E2E Test Runner ║
108
+ ╚══════════════════════════════════════════╝
109
+ `);
110
+
111
+ log.info("test", ` Game: ${opts.game}`);
112
+ log.info("test", ` Spec: ${specPath}`);
113
+ log.info("test", ` MCP port: ${opts.mcpPort}`);
114
+ log.info("test", ` Renderer: ${opts.renderer === "cpu" ? "SwiftShader (software)" : "hardware GPU"}`);
115
+ log.info("test", ` Deterministic: ${opts.deterministic}`);
116
+ log.info("test", ` Headed: ${opts.headed}`);
117
+ if (opts.verbose) log.info("test", ` Verbose: on`);
118
+ log.info("test", "");
119
+
120
+ const env: Record<string, string> = {
121
+ ...process.env,
122
+ MCP_PORT: String(opts.mcpPort),
123
+ MCP_TIMEOUT_MS: "120000",
124
+ };
125
+
126
+ if (opts.renderer === "cpu") {
127
+ env.DOWNDRAFT_GPU = "swiftshader";
128
+ } else {
129
+ env.DOWNDRAFT_GPU = "hardware";
130
+ }
131
+ if (opts.deterministic) {
132
+ env.DOWNDRAFT_DETERMINISTIC = "1";
133
+ }
134
+ if (opts.headed) {
135
+ // Show the window instead of running headless.
136
+ env.DOWNDRAFT_HEADED = "1";
137
+ }
138
+
139
+ // If no display is available, wrap in xvfb-run (common in CI).
140
+ let cmd = "bun";
141
+ let cmdArgs = ["test", specPath];
142
+ const noDisplay = !env.DISPLAY;
143
+ if (noDisplay && hasXvfb()) {
144
+ log.info("test", "No DISPLAY detected — wrapping in xvfb-run");
145
+ cmd = "xvfb-run";
146
+ cmdArgs = ["-a", "--", "bun", "test", specPath];
147
+ } else if (noDisplay) {
148
+ log.warn("test", "No DISPLAY and xvfb-run not found — the native window may fail to start.");
149
+ log.warn("test", "Install xvfb with: sudo apt install xvfb");
150
+ }
151
+
152
+ const child = spawn(cmd, cmdArgs, {
153
+ cwd: monorepoRoot ?? gameDir ?? process.cwd(),
154
+ stdio: "inherit",
155
+ env,
156
+ });
157
+
158
+ const shutdown = () => {
159
+ try { child.kill("SIGINT"); } catch {}
160
+ setTimeout(() => {
161
+ try { process.kill(-child.pid!, "SIGKILL"); } catch {}
162
+ process.exit(130);
163
+ }, 2000);
164
+ };
165
+ process.on("SIGINT", shutdown);
166
+ process.on("SIGTERM", shutdown);
167
+
168
+ // Await the child process so `draft test` blocks until the spec (and the
169
+ // spawned game) fully finishes + cleans up. Without this, the async
170
+ // function resolves immediately after spawning while the game is still
171
+ // booting, and the next sequential e2e run starts concurrently — colliding
172
+ // on the MCP port.
173
+ const exitCode = await new Promise<number>((resolve) => {
174
+ child.on("exit", (code) => resolve(code ?? 1));
175
+ child.on("error", (err) => {
176
+ log.error("test", `Failed to spawn test process: ${err.message}`);
177
+ resolve(1);
178
+ });
179
+ });
180
+
181
+ if (exitCode === 0) {
182
+ log.info("test", "All tests passed.");
183
+ } else {
184
+ log.error("test", `Tests failed with exit code ${exitCode}.`);
185
+ }
186
+ process.exit(exitCode);
187
+ }