Files
SpineWallpaper/tools/lib/vault.ts
T
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

253 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 把 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<Wallpaper> {
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<WallpaperMeta>(metaPath);
const preset = await readJson<PresetTemplate>(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<Game> {
const srcRel = `wallpapers/${gameId}`;
const meta = await readJson<GameMeta>(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<Vault> {
const global = await readJson<GlobalMeta>(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<string, string>();
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;
}