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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
// 抓取器(tools/downloader/)的门:**先证明它会红**。
//
// 四件事,缺一不可:
// 1. 语法门:整个包能编译(Python 不在 `pnpm check` 的任何一道门里,这是它唯一的机会)
// 2. 绿:自造一个形状正确的产物夹具 → `verify` 必须退出 0
// 3. 红:把夹具弄坏几处 → `verify` 必须退出非 0(点名的必须是**刚弄坏的那处**)
// 4. 还原:必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
//
// 夹具是自造的,不依赖 `_out/`(那是 gitignore 的 staging,别人机器上不一定有),也不联网。
// 形状 = 「一个场景一个可直接搬走的壁纸目录」(见 tools/downloader/layout.py)。
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const PY = process.env.PYTHON ?? "python";
const FIXTURE = join(ROOT, "tools", ".cache", "check-downloader");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 跑一条 python 命令,返回 { code, out }。 */
function run(args: string[], cwd = ROOT): { code: number; out: string } {
const result = spawnSync(PY, args, { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } });
if (result.error) {
throw new Error(`跑不动 ${PY}:${result.error.message}(设 PYTHON 环境变量可指定解释器)`);
}
return { code: result.status ?? -1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
}
const PAGE = () => join(FIXTURE, "ys", "fixture-page");
const SCENE = () => join(PAGE(), "scene_main");
const sceneJson = () => join(SCENE(), "scene.json");
const presetJson = () => join(SCENE(), "preset.template.json");
const metaJson = () => join(SCENE(), "meta.json");
const heroPng = () => join(SCENE(), "spines", "hero", "hero.png");
/** 造一个形状正确的产物夹具:一页、一个场景目录、一具骨架、一块贴图平面。 */
function writeFixture(dir: string): void {
rmSync(dir, { recursive: true, force: true });
const scene = SCENE();
const spine = join(scene, "spines", "hero");
mkdirSync(spine, { recursive: true });
mkdirSync(join(scene, "scene"), { recursive: true });
mkdirSync(join(scene, "audios"), { recursive: true });
writeFileSync(
join(PAGE(), "page.json"),
JSON.stringify({
id: "fixture-page",
game: "ys",
url: "https://example.invalid/",
scenes: [{ id: "scene_main", dir: "scene_main", spines: 1, images: 1, solids: 1 }],
chosenScene: "scene_main",
chosenDir: "scene_main",
sceneDirs: [{ dir: "scene_main", scene: "scene_main", spines: 1, images: 1, solids: 1, parts: 2, bytes: 0 }],
skippedScenes: [],
totalBytes: 0,
}),
);
writeFileSync(
sceneJson(),
JSON.stringify({
version: 1,
id: "scene_main",
parts: [
{ kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1] },
{ kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], geometrySize: [100, 50] },
{ kind: "solid", id: "DEFAULT", order: 2, position: [0, 0, 0], scale: [1, 1, 1] },
],
}),
);
writeFileSync(
presetJson(),
JSON.stringify({
backgroundImage: "./scene/sky.png",
sceneConfig: {
ui: [100, 100],
parts: [
{
kind: "spine",
id: "hero",
order: 0,
position: [0, 0, 0],
scale: [1, 1, 1],
jsonUrl: "./spines/hero/hero.json",
atlasUrl: "./spines/hero/hero.atlas",
},
{
kind: "image",
id: "sky",
order: 1,
position: [0, 0, 0],
scale: [1, 1, 1],
width: 100,
height: 50,
image: "./scene/sky.png",
},
],
},
}),
);
writeFileSync(
metaJson(),
JSON.stringify({
id: "scene_main",
name: "夹具页(scene_main)",
title: "夹具页(scene_main)",
description: "夹具",
game: "ys",
page: "fixture-page",
scene: "scene_main",
source: "https://example.invalid/",
audio: { choices: [] },
}),
);
writeFileSync(
join(spine, "hero.json"),
JSON.stringify({ skeleton: { spine: "4.2.43", images: "" }, bones: [], slots: [] }),
);
writeFileSync(join(spine, "hero.atlas"), "hero.png\nsize:8,8\nfilter:Linear,Linear\nbody\nbounds:0,0,4,4\n");
writeFileSync(heroPng(), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(spine, "meta.json"), JSON.stringify({ name: "hero", spine: "4.2.43", pages: ["hero.png"] }));
writeFileSync(join(scene, "scene", "sky.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
}
console.log("抓取器门:\n");
// ① 前置条件:解释器与包目录都在
const pre = run(["-c", "import sys; print(sys.version.split()[0])"]);
check(pre.code === 0, `${PY} 可用`, pre.out.split("\n")[0] || pre.out);
if (pre.code !== 0) {
console.log("\n前置条件不满足,无法继续。");
process.exit(1);
}
// ② 语法门
const compile = run(["-m", "compileall", "-q", "tools/downloader"]);
check(compile.code === 0, "tools/downloader 全部能编译", compile.out.split("\n").slice(-2).join(" | ") || "无输出");
// ③ 绿:形状正确的夹具必须通过
writeFixture(FIXTURE);
const good = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(good.code === 0, "形状正确的夹具 → verify 退出 0", good.code === 0 ? good.out.split("\n").pop() : good.out);
// ④ 红之一:贴图页缺文件(atlas 声明的页名必须逐字落盘)
const savedPng = readFileSync(heroPng());
rmSync(heroPng());
const missingPage = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(missingPage.code !== 0, "删掉贴图页 → verify 报红", missingPage.out.split("\n")[0]);
check(/hero\.png/.test(missingPage.out), "报错里点名了缺的那张页", missingPage.out.split("\n")[0]);
writeFileSync(heroPng(), savedPng);
// ⑤ 红之二:侧车引用一具不存在的骨架
const savedScene = readFileSync(sceneJson(), "utf8");
const scene = JSON.parse(savedScene) as { parts: { kind: string; id: string; order?: number }[] };
scene.parts.push({ kind: "spine", id: "ghost", order: 3 });
writeFileSync(sceneJson(), JSON.stringify(scene));
const ghost = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(ghost.code !== 0, "侧车引用不存在的骨架 → verify 报红", ghost.out.split("\n")[0]);
check(/ghost/.test(ghost.out), "报错里点名了 ghost", ghost.out.split("\n")[0]);
writeFileSync(sceneJson(), savedScene);
// ⑥ 红之三:预设里有一条 ./… 指向不存在的文件(构建期才会炸的那类)
const savedPreset = readFileSync(presetJson(), "utf8");
const preset = JSON.parse(savedPreset) as { backgroundImage: string };
preset.backgroundImage = "./scene/nope.png";
writeFileSync(presetJson(), JSON.stringify(preset));
const badPath = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badPath.code !== 0, "预设路径指向不存在的文件 → verify 报红", badPath.out.split("\n")[0]);
check(/nope\.png/.test(badPath.out), "报错里点名了那条路径", badPath.out.split("\n")[0]);
writeFileSync(presetJson(), savedPreset);
// ⑦ 红之四:meta.json 的 id 与场景目录名不一致(搬进 wallpapers/ 会直接构建报错)
const savedMeta = readFileSync(metaJson(), "utf8");
const meta = JSON.parse(savedMeta) as { id: string };
meta.id = "other-id";
writeFileSync(metaJson(), JSON.stringify(meta));
const badId = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badId.code !== 0, "meta.json 的 id 与目录名不一致 → verify 报红", badId.out.split("\n")[0]);
check(/other-id/.test(badId.out), "报错里点名了那个 id", badId.out.split("\n")[0]);
writeFileSync(metaJson(), savedMeta);
// ⑧ 还原后必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
const restored = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(restored.code === 0, "还原四处破坏 → 重新变绿", restored.code === 0 ? restored.out.split("\n").pop() : restored.out);
rmSync(FIXTURE, { recursive: true, force: true });
console.log(failures === 0 ? "\n抓取器门:通过" : `\n抓取器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);