@downdraft/cli 0.0.0 → 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.
- package/README.md +11 -0
- package/package.json +36 -3
- package/scripts/package-mobile.mjs +779 -0
- package/scripts/package-native.mjs +616 -0
- package/src/args.ts +333 -0
- package/src/assets.ts +523 -0
- package/src/blob-store-s3.ts +145 -0
- package/src/build-games.ts +45 -0
- package/src/build.ts +40 -0
- package/src/bun-env.d.ts +6 -0
- package/src/debug.ts +56 -0
- package/src/dev.ts +231 -0
- package/src/dist.ts +36 -0
- package/src/export.ts +38 -0
- package/src/index.ts +131 -0
- package/src/list-games.ts +37 -0
- package/src/mcp.ts +320 -0
- package/src/new.ts +63 -0
- package/src/paths.ts +101 -0
- package/src/plugin-command.ts +214 -0
- package/src/process-utils.ts +179 -0
- package/src/release.ts +374 -0
- package/src/scaffold-plugin.ts +280 -0
- package/src/scaffold.ts +207 -0
- package/src/test.ts +187 -0
- package/src/usage.ts +255 -0
- package/templates/_shared/ai-companion/devin-config.eta +11 -0
- package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
- package/templates/_shared/ai-companion/logging-rule.eta +3 -0
- package/templates/_shared/ai-companion/loops-rule.eta +3 -0
- package/templates/_shared/partials/config-json-fields.eta +3 -0
- package/templates/_shared/partials/dev-deps.eta +8 -0
- package/templates/_shared/partials/package-json-fields.eta +12 -0
- package/templates/_shared/partials/tsconfig.eta +16 -0
- package/templates/_shared/partials/vscode-extensions.eta +11 -0
- package/templates/_shared/partials/vscode-settings.eta +29 -0
- package/templates/_shared/partials/vscode-tasks.eta +93 -0
- package/templates/full/.gitignore.eta +5 -0
- package/templates/full/.vscode/extensions.json.eta +1 -0
- package/templates/full/.vscode/settings.json.eta +1 -0
- package/templates/full/.vscode/tasks.json.eta +1 -0
- package/templates/full/README.md.eta +45 -0
- package/templates/full/assets/shaders/.eta +0 -0
- package/templates/full/downdraft.config.json.eta +10 -0
- package/templates/full/package.json.eta +9 -0
- package/templates/full/src/entities/.eta +0 -0
- package/templates/full/src/game-module.ts.eta +30 -0
- package/templates/full/src/native-entry.ts.eta +14 -0
- package/templates/full/src/sim-host.ts.eta +19 -0
- package/templates/full/src/sim-worker.ts.eta +99 -0
- package/templates/full/src/systems/.eta +0 -0
- package/templates/full/tsconfig.json.eta +1 -0
- package/templates/gamemodule/README.md.eta +32 -0
- package/templates/gamemodule/downdraft.config.json.eta +3 -0
- package/templates/gamemodule/package.json.eta +9 -0
- package/templates/gamemodule/src/game-module.ts.eta +63 -0
- package/templates/gamemodule/src/native-entry.ts.eta +14 -0
- package/templates/gamemodule/src/sim-host.ts.eta +25 -0
- package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
- package/templates/gamemodule/tsconfig.json.eta +1 -0
- package/templates/minimal/.gitignore.eta +5 -0
- package/templates/minimal/.vscode/extensions.json.eta +1 -0
- package/templates/minimal/.vscode/settings.json.eta +1 -0
- package/templates/minimal/.vscode/tasks.json.eta +1 -0
- package/templates/minimal/README.md.eta +32 -0
- package/templates/minimal/downdraft.config.json.eta +3 -0
- package/templates/minimal/package.json.eta +9 -0
- package/templates/minimal/src/game-module.ts.eta +44 -0
- package/templates/minimal/src/native-entry.ts.eta +14 -0
- package/templates/minimal/tsconfig.json.eta +1 -0
- package/templates/physics/.gitignore.eta +5 -0
- package/templates/physics/.vscode/extensions.json.eta +1 -0
- package/templates/physics/.vscode/settings.json.eta +1 -0
- package/templates/physics/.vscode/tasks.json.eta +1 -0
- package/templates/physics/README.md.eta +36 -0
- package/templates/physics/downdraft.config.json.eta +6 -0
- package/templates/physics/package.json.eta +9 -0
- package/templates/physics/src/game-module.ts.eta +29 -0
- package/templates/physics/src/native-entry.ts.eta +14 -0
- package/templates/physics/src/sim-host.ts.eta +19 -0
- package/templates/physics/src/sim-worker.ts.eta +155 -0
- package/templates/physics/tsconfig.json.eta +1 -0
package/src/release.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// draft release — native build + package pipeline
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Replaces the separate `build`, `dist`, `export`, `mobile`, and
|
|
6
|
+
// `build-games` commands with a single pipeline:
|
|
7
|
+
//
|
|
8
|
+
// draft release [--game=<name>] [--target=<csv>]
|
|
9
|
+
// (auto-detects the game from cwd or games/ when --game is omitted)
|
|
10
|
+
// [--stage=<build|package|release>] [--games=<csv>]
|
|
11
|
+
// [--mode=<dev|debug|prod>] [--out=<dir>]
|
|
12
|
+
// [--mcp] [--verbose]
|
|
13
|
+
//
|
|
14
|
+
// Stages (all stages produce the same artifact — a compiled Bun binary plus
|
|
15
|
+
// the staged native/ + dd-assets/ tree — via scripts/package-native.mjs):
|
|
16
|
+
// build = compile only
|
|
17
|
+
// package = package an existing build (for native: same compile step)
|
|
18
|
+
// release = build + package + collect to release/ (default)
|
|
19
|
+
//
|
|
20
|
+
// Targets: win | linux | mac | all
|
|
21
|
+
//
|
|
22
|
+
// The old commands remain as backward-compat aliases that delegate here with
|
|
23
|
+
// a deprecation warning.
|
|
24
|
+
//
|
|
25
|
+
|
|
26
|
+
import { createLogger } from "@downdraft/engine";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { basename, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { parseArgs, print, renderHelp } from "./args";
|
|
32
|
+
import { formatGamesList, listGames } from "./list-games";
|
|
33
|
+
import { findGameDirUpward, findMonorepoRoot, resolveGameDir } from "./paths";
|
|
34
|
+
import { getCommand } from "./usage";
|
|
35
|
+
|
|
36
|
+
const log = createLogger();
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Types
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
type Stage = "build" | "package" | "release";
|
|
43
|
+
|
|
44
|
+
interface ReleaseArgs {
|
|
45
|
+
game: string | null;
|
|
46
|
+
games: string[];
|
|
47
|
+
target: string;
|
|
48
|
+
stage: Stage;
|
|
49
|
+
mode: string;
|
|
50
|
+
abi: string;
|
|
51
|
+
minSdk: string | null;
|
|
52
|
+
nodeFlavor: string | null;
|
|
53
|
+
libs: string | null;
|
|
54
|
+
excludeLibs: string | null;
|
|
55
|
+
noStrip: boolean;
|
|
56
|
+
out: string;
|
|
57
|
+
retainMcp: boolean;
|
|
58
|
+
verbose: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Arg parsing
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function parseReleaseArgs(args: string[]): ReleaseArgs {
|
|
66
|
+
const entry = getCommand("release")!;
|
|
67
|
+
const parsed = parseArgs(args, entry.schema);
|
|
68
|
+
if (parsed.help) {
|
|
69
|
+
print(renderHelp(entry.usage, entry.schema));
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const game = (parsed.flags.game as string) || null;
|
|
74
|
+
const gamesFlag = (parsed.flags.games as string) || "";
|
|
75
|
+
const games = gamesFlag
|
|
76
|
+
? gamesFlag.split(",").map((s) => s.trim()).filter(Boolean)
|
|
77
|
+
: game ? [game] : [];
|
|
78
|
+
|
|
79
|
+
const stageFlag = (parsed.flags.stage as string) || "release";
|
|
80
|
+
// --skip-build and --build-only are convenience aliases for --stage.
|
|
81
|
+
const skipBuild = parsed.flags["skip-build"] as boolean;
|
|
82
|
+
const buildOnly = parsed.flags["build-only"] as boolean;
|
|
83
|
+
let stage: Stage = stageFlag as Stage;
|
|
84
|
+
if (buildOnly) stage = "build";
|
|
85
|
+
else if (skipBuild) stage = "package";
|
|
86
|
+
|
|
87
|
+
if (parsed.flags.format) {
|
|
88
|
+
log.warn("release", "--format is gone — native packaging produces a single binary + staged tree.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
game,
|
|
93
|
+
games,
|
|
94
|
+
target: parsed.flags.target as string,
|
|
95
|
+
stage,
|
|
96
|
+
mode: (parsed.flags.mode as string) || "prod",
|
|
97
|
+
abi: (parsed.flags.abi as string) || "arm64-v8a",
|
|
98
|
+
minSdk: (parsed.flags["min-sdk"] as string) || null,
|
|
99
|
+
nodeFlavor: (parsed.flags["node-flavor"] as string) || null,
|
|
100
|
+
libs: (parsed.flags.libs as string) || null,
|
|
101
|
+
excludeLibs: (parsed.flags["exclude-libs"] as string) || null,
|
|
102
|
+
noStrip: parsed.flags["no-strip"] as boolean,
|
|
103
|
+
out: (parsed.flags.out as string) || "release",
|
|
104
|
+
retainMcp: parsed.flags.mcp as boolean,
|
|
105
|
+
verbose: parsed.flags.verbose as boolean,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Game resolution
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the game(s) to release when neither --game nor --games is given.
|
|
115
|
+
*
|
|
116
|
+
* Resolution order:
|
|
117
|
+
* 1. Explicit flags (--game / --games) — handled by the caller before this.
|
|
118
|
+
* 2. cwd inference — walk up from `process.cwd()` looking for
|
|
119
|
+
* `downdraft.config.json` or `src/native-entry.ts`. Lets games run
|
|
120
|
+
* `draft release` from their own directory (the scaffolded
|
|
121
|
+
* `package.json` sets `"release": "draft release"`).
|
|
122
|
+
* 3. Single-game fallback — if `games/` contains exactly one game, use it.
|
|
123
|
+
*
|
|
124
|
+
* Returns the detected game name, or `null` if none could be determined.
|
|
125
|
+
*/
|
|
126
|
+
function detectGame(monorepoRoot: string | null): string | null {
|
|
127
|
+
// 2. Cwd inference: walk up looking for the game-dir markers.
|
|
128
|
+
const upward = findGameDirUpward();
|
|
129
|
+
if (upward) return basename(upward);
|
|
130
|
+
|
|
131
|
+
// 3. Single-game fallback (monorepo only).
|
|
132
|
+
if (monorepoRoot) {
|
|
133
|
+
const games = listGames(monorepoRoot);
|
|
134
|
+
if (games.length === 1) {
|
|
135
|
+
log.info("release", `No game specified — defaulting to the only game found: ${games[0]}`);
|
|
136
|
+
return games[0];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Target classification
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
const DESKTOP_TARGETS = new Set(["win", "linux", "mac"]);
|
|
148
|
+
const MOBILE_TARGETS = new Set(["android"]);
|
|
149
|
+
|
|
150
|
+
function classifyTargets(target: string): string[] {
|
|
151
|
+
if (target === "all") {
|
|
152
|
+
return ["win", "linux", "mac"];
|
|
153
|
+
}
|
|
154
|
+
const parts = target.split(",").map((s) => s.trim()).filter(Boolean);
|
|
155
|
+
parts.forEach((t) => {
|
|
156
|
+
if (t === "ios") {
|
|
157
|
+
log.error("release", `iOS is not implemented yet — the native mobile port currently supports android only.`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (!DESKTOP_TARGETS.has(t) && !MOBILE_TARGETS.has(t)) {
|
|
161
|
+
log.error("release", `Unknown target: ${t}. Use win, linux, mac, android, or all.`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
if (parts.some((t) => MOBILE_TARGETS.has(t)) && parts.some((t) => DESKTOP_TARGETS.has(t))) {
|
|
166
|
+
log.error("release", `Can't mix mobile and desktop targets in one release — run android separately.`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
return parts;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Game info resolution
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
function getGameInfo(gameDir: string, game: string): { productName: string; appId: string; version: string } {
|
|
177
|
+
const pkgPath = resolve(gameDir, "package.json");
|
|
178
|
+
if (!existsSync(pkgPath)) {
|
|
179
|
+
return { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0" };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
183
|
+
const productName = pkg.productName ?? pkg.build?.productName ??
|
|
184
|
+
String(pkg.name ?? game).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
185
|
+
const appId = pkg.build?.appId ?? `com.downdraft.${game}`;
|
|
186
|
+
const version = pkg.version ?? "0.0.0";
|
|
187
|
+
return { productName, appId, version };
|
|
188
|
+
} catch {
|
|
189
|
+
return { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0" };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Native packaging — scripts/package-native.mjs
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Compile the game's native entry into a standalone Bun binary plus the
|
|
199
|
+
* staged runtime tree (<out>/<game>-<target> + sibling native/ + dd-assets/).
|
|
200
|
+
* One compile covers both the build and package stages — the binary IS the
|
|
201
|
+
* artifact.
|
|
202
|
+
*/
|
|
203
|
+
function packageNative(
|
|
204
|
+
game: string,
|
|
205
|
+
gameDir: string,
|
|
206
|
+
targets: string[],
|
|
207
|
+
opts: ReleaseArgs,
|
|
208
|
+
info: { productName: string; appId: string; version: string },
|
|
209
|
+
): boolean {
|
|
210
|
+
const entry = resolve(gameDir, "src/native-entry.ts");
|
|
211
|
+
if (!existsSync(entry)) {
|
|
212
|
+
log.error("release:package", `No src/native-entry.ts in ${gameDir} — the game has no native entry.`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
// The packaging script ships inside @downdraft/cli (files: ["scripts"]) so
|
|
216
|
+
// standalone game repos resolve it through the installed package, not the
|
|
217
|
+
// engine monorepo.
|
|
218
|
+
const script = fileURLToPath(new URL("../scripts/package-native.mjs", import.meta.url));
|
|
219
|
+
// cwd is the relativization root for staged assets — the monorepo root
|
|
220
|
+
// when present (engine sources relativize cleanly), else the game dir.
|
|
221
|
+
const cwd = findMonorepoRoot(gameDir) ?? gameDir;
|
|
222
|
+
let ok = true;
|
|
223
|
+
targets.forEach((target) => {
|
|
224
|
+
const outfile = resolve(opts.out, `${game}-${target}`);
|
|
225
|
+
const argv = [
|
|
226
|
+
script,
|
|
227
|
+
`--target=${target}`,
|
|
228
|
+
`--mode=${opts.mode}`,
|
|
229
|
+
`--product-name=${info.productName}`,
|
|
230
|
+
`--product-version=${info.version}`,
|
|
231
|
+
...(opts.retainMcp ? ["--mcp"] : []),
|
|
232
|
+
entry,
|
|
233
|
+
outfile,
|
|
234
|
+
];
|
|
235
|
+
log.info("release:package", `bun ${argv.map((a) => basename(a)).join(" ")}`);
|
|
236
|
+
const result = spawnSync("bun", argv, {
|
|
237
|
+
cwd,
|
|
238
|
+
stdio: "inherit",
|
|
239
|
+
env: process.env,
|
|
240
|
+
});
|
|
241
|
+
if (result.status !== 0) {
|
|
242
|
+
log.error("release:package", `Native packaging failed for ${game} (${target})`);
|
|
243
|
+
ok = false;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return ok;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Android packaging — scripts/package-mobile.mjs
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Bundle the game + native libs + embedded libnode into an APK. Single
|
|
255
|
+
* target ("android") produces lib/<abi>/ entries for --abi (default
|
|
256
|
+
* arm64-v8a). Unlike the desktop path the artifact is the APK itself.
|
|
257
|
+
*/
|
|
258
|
+
function packageAndroid(
|
|
259
|
+
game: string,
|
|
260
|
+
gameDir: string,
|
|
261
|
+
opts: ReleaseArgs,
|
|
262
|
+
info: { productName: string; appId: string; version: string },
|
|
263
|
+
): boolean {
|
|
264
|
+
const entry = resolve(gameDir, "src/native-entry.ts");
|
|
265
|
+
if (!existsSync(entry)) {
|
|
266
|
+
log.error("release:package", `No src/native-entry.ts in ${gameDir} — the game has no native entry.`);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const script = fileURLToPath(new URL("../scripts/package-mobile.mjs", import.meta.url));
|
|
270
|
+
const cwd = findMonorepoRoot(gameDir) ?? gameDir;
|
|
271
|
+
const outfile = resolve(opts.out, `${game}-android.apk`);
|
|
272
|
+
const argv = [
|
|
273
|
+
script,
|
|
274
|
+
`--mode=${opts.mode}`,
|
|
275
|
+
`--abi=${opts.abi}`,
|
|
276
|
+
`--app-id=${info.appId}`,
|
|
277
|
+
`--app-name=${info.productName}`,
|
|
278
|
+
`--version=${info.version}`,
|
|
279
|
+
...(opts.minSdk ? [`--min-sdk=${opts.minSdk}`] : []),
|
|
280
|
+
...(opts.nodeFlavor ? [`--node-flavor=${opts.nodeFlavor}`] : []),
|
|
281
|
+
...(opts.libs ? [`--libs=${opts.libs}`] : []),
|
|
282
|
+
...(opts.excludeLibs ? [`--exclude-libs=${opts.excludeLibs}`] : []),
|
|
283
|
+
...(opts.noStrip ? ["--no-strip"] : []),
|
|
284
|
+
...(opts.retainMcp ? ["--mcp"] : []),
|
|
285
|
+
entry,
|
|
286
|
+
outfile,
|
|
287
|
+
];
|
|
288
|
+
log.info("release:package", `bun ${argv.map((a) => basename(a)).join(" ")}`);
|
|
289
|
+
const result = spawnSync("bun", argv, { cwd, stdio: "inherit", env: process.env });
|
|
290
|
+
if (result.status !== 0) {
|
|
291
|
+
log.error("release:package", `Android packaging failed for ${game}`);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Main entry point
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
export async function release(args: string[]): Promise<void> {
|
|
302
|
+
const opts = parseReleaseArgs(args);
|
|
303
|
+
// Monorepo root when running inside the engine repo; null for standalone
|
|
304
|
+
// game repos (the game directory itself is the project root).
|
|
305
|
+
const monorepoRoot = findMonorepoRoot();
|
|
306
|
+
|
|
307
|
+
// Auto-detect the game when neither --game nor --games is given.
|
|
308
|
+
let games = opts.games;
|
|
309
|
+
if (games.length === 0) {
|
|
310
|
+
const detected = detectGame(monorepoRoot);
|
|
311
|
+
if (detected) {
|
|
312
|
+
games = [detected];
|
|
313
|
+
opts.game = detected;
|
|
314
|
+
} else {
|
|
315
|
+
log.error("release", "No game specified and none could be auto-detected.");
|
|
316
|
+
log.error("release", "Run \"draft release\" from a game directory, or use \"--game <name>\" / \"--games <csv>\" from the engine root.");
|
|
317
|
+
log.error("release", "Available games:");
|
|
318
|
+
print(formatGamesList(monorepoRoot ?? process.cwd()));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const targets = classifyTargets(opts.target);
|
|
324
|
+
|
|
325
|
+
log.info("release", `
|
|
326
|
+
╔══════════════════════════════════════════╗
|
|
327
|
+
║ DownDraft Engine — Release ║
|
|
328
|
+
╚══════════════════════════════════════════╝
|
|
329
|
+
`);
|
|
330
|
+
|
|
331
|
+
log.info("release", ` Game(s): ${games.join(", ")}`);
|
|
332
|
+
log.info("release", ` Target: ${opts.target}`);
|
|
333
|
+
log.info("release", ` Stage: ${opts.stage}`);
|
|
334
|
+
log.info("release", ` Mode: ${opts.mode}`);
|
|
335
|
+
log.info("release", ` Output: ${opts.out}`);
|
|
336
|
+
if (opts.verbose) log.info("release", ` Verbose: on`);
|
|
337
|
+
log.info("release", "");
|
|
338
|
+
|
|
339
|
+
let fail = 0;
|
|
340
|
+
|
|
341
|
+
for (let _i = 0, _it = games, _n = _it.length; _i < _n; _i++) { const game = _it[_i];
|
|
342
|
+
const gameDir = resolveGameDir(game) ?? resolve(monorepoRoot ?? process.cwd(), "games", game);
|
|
343
|
+
const { productName, appId, version } = getGameInfo(gameDir, game);
|
|
344
|
+
log.info("release", `Game: ${game} | Product: "${productName}" | AppId: ${appId} | v${version}`);
|
|
345
|
+
log.info("release", "");
|
|
346
|
+
|
|
347
|
+
// Native packaging is a single compile step — build/package/release
|
|
348
|
+
// all produce the same artifact.
|
|
349
|
+
const isAndroid = targets.every((t) => MOBILE_TARGETS.has(t));
|
|
350
|
+
log.info("release", `[package] Compiling ${game} (${targets.join(", ")})...`);
|
|
351
|
+
const ok = isAndroid
|
|
352
|
+
? packageAndroid(game, gameDir, opts, { productName, appId, version })
|
|
353
|
+
: packageNative(game, gameDir, targets, opts, { productName, appId, version });
|
|
354
|
+
if (!ok) {
|
|
355
|
+
log.error("release", `Packaging failed for ${game} — skipping.`);
|
|
356
|
+
fail = 1;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
log.info("release", `${game} done.`);
|
|
361
|
+
log.info("release", "");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Summary
|
|
365
|
+
log.info("release", "");
|
|
366
|
+
if (fail) {
|
|
367
|
+
log.error("release", "One or more games failed — see output above.");
|
|
368
|
+
} else {
|
|
369
|
+
log.info("release", "All selected games processed successfully.");
|
|
370
|
+
}
|
|
371
|
+
log.info("release", `Artifacts: ${opts.out}/`);
|
|
372
|
+
|
|
373
|
+
process.exit(fail);
|
|
374
|
+
}
|
|
@@ -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
|
+
}
|