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

+157
View File
@@ -0,0 +1,157 @@
// 静态预览验收:证明 WE 模拟面板**不依赖调试服**,且是**右边缘滑出**的。
//
// 为什么要有这个测试:
// ① 面板曾经只在调试服注入时才存在。用户要求 GitHub Pages 预览也能弹出它——
// 静态托管没有调试服,所以必须由 `--with-sim` 把驱动**注入 index.html**。
// ② Pages 把站点放在 `/<repo>/` **子路径**下。驱动若用根绝对路径 `/scripts/…`,
// 在子路径下必然 404。所以这个测试刻意把分发挂在 `/repo/` 前缀后面服务,
// 而不是挂在根上——挂根上测不出这个错。
//
// 用法:node tools/checks/verify-static-preview.mts [分发目录名]
// 前提:已经 `pnpm build --with-sim`。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { withPage, sleep } from "../cdp.mjs";
const DIR = process.argv[2] ?? "collection-all";
const PORT = Number(process.env.STATIC_PORT ?? 8195);
const ROOT = resolve("dist/releases");
/** 模拟 GitHub Pages 的 /<repo>/ 子路径。**不能**是空串,否则测不出根绝对路径的错。 */
const PREFIX = "/repo";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
};
const notFound: string[] = [];
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
if (!pathname.startsWith(`${PREFIX}/`)) {
notFound.push(pathname);
res.writeHead(404).end("outside prefix");
return;
}
let rel = pathname.slice(PREFIX.length + 1);
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = join(ROOT, normalize(rel));
if (!file.startsWith(ROOT)) {
res.writeHead(403).end("escape");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
notFound.push(pathname);
res.writeHead(404).end("not found");
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "content-length": info.size });
createReadStream(file).pipe(res);
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}${PREFIX}/${DIR}/`;
console.log(`静态托管(模拟 Pages 子路径):${BASE}\n`);
const W = 1920;
const H = 1080;
const EDGE_X = W - 8; // 16px 热区之内
const AWAY_X = 200; // 远离右边缘
const result = await withPage({ width: W, height: H, profile: "tools/.cache/edge-static" }, async ({ send, evaluate, consoleLines, exceptions }) => {
const move = async (x, y) => {
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
await sleep(320); // > 面板 .18s 过渡
};
const open = () => evaluate(`document.getElementById("wesim").dataset.open`);
await send("Page.navigate", { url: BASE });
await sleep(3500);
const initial = await evaluate(`(function(){
var p = document.getElementById("wesim");
var e = document.getElementById("wesim-edge");
return {
sim: !!window.__weSim,
isSimulated: window.__weSim ? window.__weSim.__isSimulated : null,
driverOnce: window.__weSimDriver === true,
panel: !!p, edge: !!e,
open: p ? p.dataset.open : null,
banner: p && p.querySelector(".banner") ? p.querySelector(".banner").textContent : null,
canvas: document.querySelectorAll("canvas").length,
hasToggle: !!document.querySelector("#wesim .toggle")
};
})()`);
// ── 交互:右边缘滑出 ──
await move(AWAY_X, H / 2);
const closedInitially = await open();
await move(EDGE_X, H / 2);
const openedByEdge = await open();
await move(AWAY_X, H / 2);
const closedByLeave = await open();
await move(EDGE_X, H / 2);
const reopened = await open();
// 取消收起:此刻真实指针就停在右边缘热区里。没有 armed 规则的话,
// 面板滑走会立刻让边缘命中再次触发,表现为"取消关不掉"。
// (官方面板没有 ×,底部是确认/取消,所以这里点「取消」。)
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(400);
const afterClose = await open();
// 离开边缘再回来 → 重新武装
await move(AWAY_X, H / 2);
await move(EDGE_X, H / 2);
const afterRearm = await open();
return { initial, closedInitially, openedByEdge, closedByLeave, reopened, afterClose, afterRearm, consoleLines, exceptions };
});
const r = result;
console.log("=== ① 静态托管下模拟器可用(无调试服)===");
check(r.initial.sim === true, "window.__weSim 存在(驱动已随产物注入)");
check(r.initial.isSimulated === true, "window.__weSim.__isSimulated = true");
check(r.initial.driverOnce === true, "驱动幂等标记已置位");
check(r.initial.panel === true, "#wesim 面板已挂载");
check(r.initial.edge === true, "#wesim-edge 右边缘热区已挂载");
check(r.initial.hasToggle === false, "没有常驻开关按钮(打开方式只有右边缘)");
check(/模拟环境/.test(r.initial.banner ?? ""), "面板有「模拟环境」横幅", r.initial.banner?.trim());
check(r.initial.canvas > 0, "壁纸本身也渲染了", `canvas=${r.initial.canvas}`);
console.log("\n=== ② 右边缘滑出交互 ===");
check(r.closedInitially === "0", "初始收起", `data-open=${r.closedInitially}`);
check(r.openedByEdge === "1", "指针移到右边缘 → 滑出", `data-open=${r.openedByEdge}`);
check(r.closedByLeave === "0", "指针离开 → 自动收回", `data-open=${r.closedByLeave}`);
check(r.reopened === "1", "再次移到右边缘 → 再次滑出", `data-open=${r.reopened}`);
check(r.afterClose === "0", "「取消」收起后不会被边缘热区立刻弹回", `data-open=${r.afterClose}`);
check(r.afterRearm === "1", "离开边缘后再回来 → 重新可触发", `data-open=${r.afterRearm}`);
console.log("\n=== ③ 子路径下没有 404(相对路径正确)===");
check(notFound.length === 0, "静态服务器 0 个 404", notFound.slice(0, 3).join(" | ") || "无");
const noisy = r.consoleLines.filter((l) => /error|uncaught/i.test(l));
check(r.exceptions.length === 0, "0 未捕获异常", r.exceptions.slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
server.close();
console.log(failures === 0 ? "\n静态预览:通过(面板不依赖调试服,且随右边缘滑出)" : `\n静态预览:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);