把项目从「手写 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 处引用自包含。
195 lines
8.6 KiB
TypeScript
195 lines
8.6 KiB
TypeScript
// 抓取器(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);
|