Files
SpineWallpaper/tools/checks/check-downloader.mts
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

195 lines
8.6 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.
// 抓取器(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);