// 把 wallpapers/ 读成内存里的领域对象,并在读的过程中把所有不变量校验掉。 // // 校验失败一律 fail-fast:宁可 build 报错,也不要产出一个"能上传但跑不对"的分发目录。 import { join } from "node:path"; import { abs, isFile, listDirs, readJson } from "./fs.ts"; import type { AudioChoice, AudioChoiceSpec, Game, GameMeta, GlobalMeta, Wallpaper, WallpaperMeta, PresetTemplate, } from "./types.ts"; /** CEF(WE 内置的 Chromium 146)实际能解的音频扩展名白名单。 * 实测其 FFmpeg 构建含 vorbis/libopus/flac/mp3/pcm_*,**不含 aac**,所以 m4a 直接拒。 */ const AUDIO_EXT = new Set([".flac", ".mp3", ".ogg", ".opus", ".wav"]); /** 游戏目录与壁纸目录里的保留名:不允许被当作 id 使用。 */ const RESERVED = new Set(["audios", "meta.json", "preview.gif", "preset.template.json"]); export class VaultError extends Error {} function fail(message: string): never { throw new VaultError(message); } function assert(condition: unknown, message: string): asserts condition { if (!condition) fail(message); } /** 音频文件必须是白名单内的扩展名,且不许出现在上级目录(分发时会被搬走)。 */ function checkAudioFile(file: string, where: string): string { const normalized = file.replace(/^\.\//, ""); assert(!normalized.startsWith("../"), `${where}: 音频 ${file} 不得引用上级目录(build 会把它放到分发目录内部)`); assert(!normalized.includes("\\"), `${where}: 音频路径 ${file} 必须用 / 分隔`); const dot = normalized.lastIndexOf("."); assert(dot > 0, `${where}: 音频 ${file} 没有扩展名`); const ext = normalized.slice(dot).toLowerCase(); assert( AUDIO_EXT.has(ext), `${where}: 音频 ${file} 的扩展名 ${ext} 不在 CEF 白名单内(${[...AUDIO_EXT].join(" ")});m4a/aac 在 WE 里解不了`, ); return normalized; } /** * 读一份音频声明:只校验显示名与文件,**不校验 id**——id 已经不由人写。 * 也接受裸数组(游戏级共享音频就是数组)。 * * `default` 是 **1 起**的位置,这里换算成 0 起的 `defaultIndex`: * 换算只做这一处,下游拿到的永远是能直接下标的东西。 */ function readAudioDecl( audio: { default?: number; choices?: AudioChoiceSpec[] } | AudioChoiceSpec[] | undefined, where: string, ): { defaultIndex: number; choices: AudioChoiceSpec[] } { const normalized = Array.isArray(audio) ? { choices: audio } : audio; if (!normalized) fail(`${where}: 缺少 audio 声明`); assert(Array.isArray(normalized.choices), `${where}: audio.choices 必须是数组`); const choices: AudioChoiceSpec[] = normalized.choices.map((choice, i) => { assert(typeof choice.name === "string" && choice.name.length > 0, `${where}: 第 ${i + 1} 条音源缺少显示名`); return { name: choice.name, file: checkAudioFile(choice.file, where) }; }); let defaultIndex = 0; if (normalized.default !== undefined) { assert(Number.isInteger(normalized.default), `${where}: audio.default 必须是整数位置(1 起)`); assert( normalized.default >= 1 && normalized.default <= choices.length, `${where}: audio.default ${normalized.default} 超出范围(共 ${choices.length} 条,位置从 1 起)`, ); defaultIndex = normalized.default - 1; } return { defaultIndex, choices }; } /** * 音源 id 的分配器。 * * **必须是全项目一个计数器**,不能每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里所有壁纸的 * 音源平铺进同一个 combo,两档都叫 "1" 的话选中的到底是哪个就无从分辨。 * 分配顺序固定(游戏 → 该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里 * 拿到的 id 都一样。 */ function createAudioIdAllocator(): () => string { let next = 0; return () => String((next += 1)); } /** 读一份壁纸(含其所属游戏与全局元数据)。 */ async function readWallpaper(gameId: string, wallpaperId: string, game: GameMeta): Promise { const srcRel = `wallpapers/${gameId}/${wallpaperId}`; const srcAbs = abs(srcRel); const where = srcRel; const metaPath = join(srcAbs, "meta.json"); const presetPath = join(srcAbs, "preset.template.json"); assert(await isFile(metaPath), `${where}: 缺少 meta.json`); assert(await isFile(presetPath), `${where}: 缺少 preset.template.json`); const meta = await readJson(metaPath); const preset = await readJson(presetPath); assert(meta.id === wallpaperId, `${where}: meta.json 的 id "${meta.id}" 与目录名 "${wallpaperId}" 不一致`); assert(!RESERVED.has(meta.id), `${where}: id "${meta.id}" 是保留名`); assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${where}: id "${meta.id}" 只能用字母数字与 _-(它会进 WE 的 combo value)`); assert(typeof meta.name === "string" && meta.name.length > 0, `${where}: 缺少显示名 name`); assert(typeof meta.title === "string" && meta.title.length > 0, `${where}: 缺少 title(会进 project.json)`); assert(typeof meta.description === "string" && meta.description.length > 0, `${where}: 缺少 description`); if (meta.preview !== undefined) { assert(await isFile(join(srcAbs, meta.preview)), `${where}: preview "${meta.preview}" 不存在`); } const audioDecl = readAudioDecl(meta.audio, where); for (const choice of audioDecl.choices) { assert(await isFile(join(srcAbs, choice.file)), `${where}: 音源 "${choice.name}" 指向的 ${choice.file} 不存在`); } // 运行时配置里的资源路径也要存在——这是"运行时才会暴露的拼写错误"的唯一静态防线。 const spineConfig = preset.spineConfig; const sceneConfig = preset.sceneConfig; assert( Boolean(spineConfig) !== Boolean(sceneConfig), `${where}: preset.template.json 必须**二选一**地写 spineConfig(单骨架)或 sceneConfig(场景)`, ); const resourcePaths: [string, string | undefined][] = [["backgroundImage", preset.backgroundImage]]; if (spineConfig) { resourcePaths.push(["spineConfig.jsonUrl", spineConfig.jsonUrl], ["spineConfig.atlasUrl", spineConfig.atlasUrl]); } if (sceneConfig) { assert(Array.isArray(sceneConfig.parts) && sceneConfig.parts.length > 0, `${where}: sceneConfig.parts 不能为空`); sceneConfig.parts.forEach((part, index) => { const at = `sceneConfig.parts[${index}](${part.kind} ${part.id})`; assert( part.kind === "spine" || part.kind === "image" || part.kind === "solid", `${where}: ${at} 的 kind 只能是 spine/image/solid`, ); assert(typeof part.order === "number", `${where}: ${at} 缺少 order(绘制层级)`); if (part.kind === "spine") { resourcePaths.push([`${at}.jsonUrl`, part.jsonUrl], [`${at}.atlasUrl`, part.atlasUrl]); } else if (part.kind === "image") { resourcePaths.push([`${at}.image`, part.image]); } }); } for (const [key, value] of resourcePaths) { assert(typeof value === "string" && value.length > 0, `${where}: preset.template.json 的 ${key} 缺失`); const relPath = value.replace(/^\.\//, ""); assert(await isFile(join(srcAbs, relPath)), `${where}: preset.template.json 的 ${key} 指向的 ${relPath} 不存在`); } // audioChoices 先留空:id 要等所有游戏都读完才能按固定顺序分配(见 readVault)。 return { id: wallpaperId, gameId, srcRel, meta, preset, game, audioDecl, audioChoices: [] }; } /** 读一个游戏目录。 */ async function readGame(gameId: string): Promise { const srcRel = `wallpapers/${gameId}`; const meta = await readJson(join(abs(srcRel), "meta.json")); assert(meta.id === gameId, `${srcRel}: meta.json 的 id "${meta.id}" 与目录名 "${gameId}" 不一致`); assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${srcRel}: 游戏 id 只能用字母数字与 _-`); assert(typeof meta.name === "string" && meta.name.length > 0, `${srcRel}: 缺少显示名 name`); // 游戏合集的文案就来自这一层,**不回落全局 meta**——全局那层是给「全部合集」写的,回落正是 // collection-ys 顶着《崩坏:星穹铁道》昔涟的原因(见 .scratch/build-pipeline/issues/20)。 assert(typeof meta.title === "string" && meta.title.length > 0, `${srcRel}: 缺少 title(游戏合集的创意工坊标题)`); assert( meta.description === undefined || (typeof meta.description === "string" && meta.description.length > 0), `${srcRel}: description 要么不写,要么非空(缺省时由构建按「共 N 档」生成)`, ); // 游戏级共享音频:只在「合集」类分发里被使用,允许为空(本仓库当前就是空的)。 readAudioDecl(meta.audios ?? [], `${srcRel} 的共享音频`); const wallpaperIds = await listDirs(abs(srcRel), ["audios"]); assert(wallpaperIds.length > 0, `${srcRel}: 没有任何壁纸目录`); const wallpapers = []; for (const id of wallpaperIds) wallpapers.push(await readWallpaper(gameId, id, meta)); // sharedAudio 同样等 readVault 分配 id。 return { id: gameId, meta, srcRel, wallpapers, sharedAudio: [] }; } export interface Vault { global: GlobalMeta; games: Game[]; /** 所有壁纸,按 游戏 → 壁纸 的声明顺序。 */ wallpapers: Wallpaper[]; } /** 读取整棵资源库,并校验全局唯一性。 */ export async function readVault(): Promise { const global = await readJson(abs("wallpapers/meta.json")); assert(typeof global.name === "string" && global.name.length > 0, "wallpapers/meta.json: 缺少 name"); assert(typeof global.title === "string" && global.title.length > 0, "wallpapers/meta.json: 缺少 title"); assert(typeof global.description === "string" && global.description.length > 0, "wallpapers/meta.json: 缺少 description"); const gameIds = await listDirs(abs("wallpapers"), ["audios", "meta.json", "preview.gif"]); assert(gameIds.length > 0, "wallpapers/: 没有任何游戏目录"); const games: Game[] = []; for (const id of gameIds) games.push(await readGame(id)); // 全局唯一性:WE 的 combo value 是平铺的,跨游戏撞 id 会让后一档静默覆盖前一档。 const byId = new Map(); const wallpapers: Wallpaper[] = []; for (const game of games) { for (const wallpaper of game.wallpapers) { const previous = byId.get(wallpaper.id); assert( previous === undefined, `壁纸 id "${wallpaper.id}" 在 ${previous} 与 ${wallpaper.srcRel} 中重复。` + `id 必须是全局唯一的 WE combo value(老用户设置靠它,不能自动加前缀绕过),请改其中一个。`, ); byId.set(wallpaper.id, wallpaper.srcRel); wallpapers.push(wallpaper); } } // 到这里所有游戏都读完了,再统一分配音源 id。 // // 顺序固定为「游戏 → 该游戏的共享音频 → 各壁纸的音源」,且遍历的是**整棵资源库** // 而不是本次要构建的分发子集——否则同一个音源在不同分发里会拿到不同的 id, // 用户从合集切到单档时 bgm 选择就失效了。 const nextAudioId = createAudioIdAllocator(); for (const game of games) { game.sharedAudio = (game.meta.audios ?? []).map((choice) => ({ id: nextAudioId(), name: choice.name, file: checkAudioFile(choice.file, `${game.srcRel} (共享音频)`), })); for (const wallpaper of game.wallpapers) { wallpaper.audioChoices = wallpaper.audioDecl.choices.map((choice) => ({ id: nextAudioId(), name: choice.name, file: choice.file, })); } } return { global, games, wallpapers }; } /** 该游戏在合集根共享音频里声明的音源(id 已在 readVault 里分配)。 */ export function sharedAudioOf(game: Game): AudioChoice[] { return game.sharedAudio; }