// pnpm build —— 把所有分发目录编译成"能直接上传 Wallpaper Engine"的自包含作品。
//
// pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
// pnpm build --single xilian [--single kv37] 编指定单档
// pnpm build --collect hsr 编某游戏的合集
// pnpm build --collect all 编全部合集
// pnpm build --single xilian --collect all 组合:只编这两档
//
// --with-sim 顺带把可选的 WE 模拟器放进各分发的 scripts/(服务端调试用)
// --sim 额外产出**双击就能看**的自包含包 <分发根>/sim/index.html(见 lib/bundle.ts)
// --no-embed-audio --sim 时音频不内联(包小很多,但 file:// 下音频播不了)
// --strict 把 warning 升级为 fail
// --no-clean 保留本次未构建的旧分发目录(默认会删掉,保证产物不残留)
// --dry-run 只打印计划,不落盘
//
// 产物拓扑(dist/releases/
/)见 docs/adr/0005;自包含包见 docs/adr/0007。dist/ 完全可再生产,故不入库。
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import {
abs,
copyFileTo,
dirBytes,
exists,
listDirs,
log,
mb,
readJson,
resetDir,
writeJson,
writeText,
} from "./lib/fs.ts";
import { readVault, sharedAudioOf, VaultError, type Vault } from "./lib/vault.ts";
import {
copyCollectionAudio,
copyWallpaperAssets,
generatePresetIndex,
generatePresetModule,
generateProjectJson,
releasePathOf,
type GenerateContext,
} from "./lib/generate.ts";
import type { BuildOptions, DistMap, ProjectTemplate, Release } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL_STATIC } from "./lib/drivers.ts";
import { checkDist } from "./check-dist.ts";
const RELEASES_DIR = "dist/releases";
const HELP = `用法:
pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
pnpm build <壁纸id> […] 编指定壁纸(可多个)
pnpm build single [壁纸id …] 只编单档(不给 id = 全部单档)
pnpm build collect [游戏id …|all] 只编合集(不给参数 = 全部合集)
pnpm build sim [single|collect|<壁纸id> …] 编全部 + 每档产出自包含包(双击即看,不需要 pnpm dev)
--single <壁纸id> 等价于裸 id --collect <游戏id|all> 编某合集
--with-sim 让分发自带 WE 模拟器面板(静态托管预览用,见下) --sim 产出双击可看的自包含包
--no-embed-audio --sim 时音频不内联
--strict 警告即失败 --no-clean 保留旧分发目录 --dry-run 只打印计划
三种产物,别搞混:
pnpm build 干净产物 → 上传 Wallpaper Engine(不含模拟器,ADR 0006 第 1 条)
pnpm build --with-sim 自带面板 → 丢给任意静态托管(GitHub Pages 等),不需要调试服
pnpm build sim 自包含单文件 → 本地双击打开,不需要任何服务器`;
interface BuildCliOptions {
options: BuildOptions;
withSimulator: boolean;
standalone: boolean;
embedAudio: boolean;
}
/**
* 子命令解析。
*
* 三种子命令对应三种**意图**,而不是三个 flag:
* `single` —— 我在调一档壁纸,只要它;
* `collect` —— 我在调一个合集的组装(预设切换、共享音频);
* `sim` —— 我要能双击打开的成品,每档都要一份。
*
* `sim` 后面还能再跟 `single`/`collect` 收窄范围(`pnpm build sim single kv37`)。
* 不带子命令时保持原来的 flag 风格(`--single` / `--collect`),向后兼容。
*/
function parseArgs(argv: string[]): BuildCliOptions {
const options: BuildOptions = { singles: [], collect: null, strict: false, clean: true, dryRun: false };
let withSimulator = false;
let standalone = false;
let embedAudio = true;
// 先摘子命令。`sim` 后面可以再跟 `single`/`collect`/壁纸 id,所以用循环而不是单次判断。
let start = 0;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
const word = argv[start] ?? "";
if (word === "sim") {
standalone = true;
start += 1;
} else if (word === "single") {
// `pnpm build single kv37 xilian`:后面所有非 flag 的词都是壁纸 id
start += 1;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
options.singles.push(argv[start] ?? "");
start += 1;
}
// 不给 id 就是"全部单档",由 planReleases 的显式标记决定
if (options.singles.length === 0) options.onlySingles = true;
} else if (word === "collect") {
start += 1;
const games: string[] = [];
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
games.push(argv[start] ?? "");
start += 1;
}
// 无参 = 全部合集(含每个游戏的合集与「全部壁纸合集」)。
// 这里**不能**用 `collect: "all"` 表示"全部合集"——那个值在 planReleases 里只加
// 「全部壁纸合集」一项,会把 collection-hsr 漏掉。用一个显式标记走"逐游戏 + all"的路径。
if (games.length === 0 || (games.length === 1 && games[0] === "all")) options.allCollections = true;
else options.collect = games;
} else {
/**
* 其余裸词一律当壁纸 id(`pnpm build kv37`、`pnpm build sim kv37`)。
*
* 子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,
* 别的裸词都是 id。代价是"想编一个恰好叫 single 的壁纸"做不到——接受,
* 因为 id 由我们命名且都是英文小写,真撞名时改 id 比改语法便宜。
* id 不存在时由 vault 报错,那里能顺带列出所有可用 id,比"未知子命令"有用得多。
*/
options.singles.push(word);
start += 1;
}
}
for (let i = start; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--single") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError("--single 需要跟一个壁纸 id");
options.singles.push(value);
} else if (arg === "--collect") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError('--collect 需要跟一个游戏 id 或 "all"');
if (value === "all") options.collect = "all";
else options.collect = [...(Array.isArray(options.collect) ? options.collect : []), value];
} else if (arg === "--with-sim") withSimulator = true;
else if (arg === "--sim") standalone = true;
else if (arg === "--no-embed-audio") embedAudio = false;
else if (arg === "--strict") options.strict = true;
else if (arg === "--no-clean") options.clean = false;
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else if (arg.startsWith("--")) throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
else if (["sim", "single", "collect"].includes(arg)) {
// 子命令写在 --flag 后面时会被当成壁纸 id。报错时直接点破顺序,
// 否则那句"壁纸要写成 --single sim"会把人引到完全错误的方向。
throw new VaultError(
`子命令 ${arg} 必须写在所有 --flag **之前**:\n` +
` pnpm build ${arg} --with-sim ✓\n` +
` pnpm build --with-sim ${arg} ✗(这里的 ${arg} 会被当成壁纸 id)\n\n${HELP}`,
);
} else throw new VaultError(`未知参数:${arg}(壁纸要写成 --single ${arg})\n\n${HELP}`);
}
return { options, withSimulator, standalone, embedAudio };
}
function normalizeGameId(value: string, vault: Vault): string {
const wanted = value.trim().replace(/[:]/g, ":");
const exact = vault.games.find((g) => g.id === wanted);
if (exact) return exact.id;
const byName = vault.games.find((g) => g.meta.name.replace(/[:]/g, ":") === wanted);
if (byName) return byName.id;
throw new VaultError(`找不到游戏 "${value}"。已有:${vault.games.map((g) => `${g.meta.id}(${g.meta.name})`).join("、")}`);
}
/** 按子命令 / --single / --collect 求出本次要编的分发清单。无参 = 全编。 */
function planReleases(vault: Vault, options: BuildOptions): Release[] {
const releases: Release[] = [];
// `single`(无 id)只编单档;`collect`(无参)只编合集;无参才是全编。
// 三者都不能靠"singles 为空 + collect 为 null"来区分,所以各自有显式标记。
const onlySingles = options.onlySingles === true;
const onlyCollections = options.allCollections === true;
const all = !onlySingles && !onlyCollections && options.singles.length === 0 && options.collect === null;
const addSingle = (wallpaperId: string): void => {
const wallpaper = vault.wallpapers.find((w) => w.id === wallpaperId);
if (!wallpaper) {
throw new VaultError(
`找不到壁纸 "${wallpaperId}"。已有:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
);
}
releases.push({
type: "single",
id: wallpaper.id,
// 带上游戏:`wallpaper-kv37` 这种名字在分不清来源的场合(多个游戏各有一档壁纸时)
// 只能靠 id 全局唯一来兜底,而目录名本来就该自解释。与 collection-<游戏id> 同一套读法。
dir: `single-${wallpaper.gameId}-${wallpaper.id}`,
wallpapers: [wallpaper],
displayName: `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`,
meta: {
title: wallpaper.meta.title,
description: wallpaper.meta.description,
...(wallpaper.meta.preview ? { preview: wallpaper.meta.preview } : {}),
...(wallpaper.meta.workshopid ? { workshopid: wallpaper.meta.workshopid } : {}),
...(wallpaper.meta.workshopurl ? { workshopurl: wallpaper.meta.workshopurl } : {}),
},
metaRel: wallpaper.srcRel,
sharedAudio: [],
});
};
const addGame = (gameId: string): void => {
const game = vault.games.find((g) => g.id === gameId);
if (!game) throw new VaultError(`内部错误:找不到游戏 ${gameId}`);
releases.push({
type: "collection",
scope: "game",
id: game.id,
dir: `collection-${game.id}`,
wallpapers: [...game.wallpapers],
displayName: `${game.meta.name}合集`,
meta: {
// 文案取自**游戏级** meta(见 wallpapers/README.md 的三层分工),不回落到全局 title:
// 全局那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟。
title: game.meta.title,
description:
game.meta.description ??
`[b]${game.meta.name} 全部壁纸[/b]\r\n[list]\r\n [*]共 ${game.wallpapers.length} 档:${game.wallpapers
.map((w) => w.meta.name)
.join("、")}。\r\n [*]bgm 与音量可在壁纸设置中调整。\r\n[/list]`,
},
metaRel: game.srcRel,
sharedAudio: sharedAudioOf(game),
});
};
const addAll = (): void => {
releases.push({
type: "collection",
scope: "all",
id: "all",
dir: "collection-all",
wallpapers: [...vault.wallpapers],
displayName: vault.global.name,
meta: {
title: vault.global.title,
description: vault.global.description,
...(vault.global.preview ? { preview: vault.global.preview } : {}),
...(vault.global.workshopid ? { workshopid: vault.global.workshopid } : {}),
...(vault.global.workshopurl ? { workshopurl: vault.global.workshopurl } : {}),
},
metaRel: "wallpapers",
sharedAudio: vault.games.flatMap((game) => sharedAudioOf(game)),
});
};
if (all) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
if (onlySingles) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
return releases;
}
// `pnpm build collect`(无参)= 每个游戏的合集 + 「全部壁纸合集」。
// 注意 `--collect all` 是另一件事:它只编「全部壁纸合集」一项,保持原语义不变。
if (options.allCollections === true) {
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
for (const id of options.singles) addSingle(id);
if (options.collect === "all") addAll();
else if (Array.isArray(options.collect)) for (const raw of options.collect) addGame(normalizeGameId(raw, vault));
return releases;
}
/**
* 把 `index.html` 模板渲染成产物。
*
* 默认**原样输出**:发布产物的 index.html 里不能有任何模拟器痕迹(ADR 0006 第 1 条)——
* 旧实现会在真实 WE 里自我激活并覆盖官方 API,事故级。
*
* `--with-sim` 时才注入模拟器驱动,且用**相对路径** `./scripts/wallpaper-engine.js`。
* 这样整个分发目录是自足的:丢到任何静态托管(GitHub Pages 的 `//` 子路径、
* 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。
*
* 注入点必须在 `