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

+129
View File
@@ -0,0 +1,129 @@
// 文件系统与会话基元。刻意不引任何第三方依赖:构建脚本要能在一台只有 Node 的机器上跑起来。
import { createHash } from "node:crypto";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
/** 仓库根(tools/lib/fs.ts → ../..)。所有路径都相对它解析,脚本可从任意 cwd 调用。 */
export const repoRoot = resolve(import.meta.dirname, "..", "..");
/** 把仓库相对路径解析成绝对路径。 */
export function abs(rel: string): string {
return join(repoRoot, rel);
}
/** 统一成以 / 分隔的仓库相对路径,便于打印与写进 dist-map.json。 */
export function rel(absPath: string): string {
return relative(repoRoot, absPath).split(sep).join("/");
}
export async function readJson<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, "utf8")) as T;
}
export async function writeJson(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value, null, "\t") + "\n", "utf8");
}
export async function writeText(path: string, value: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, value.endsWith("\n") ? value : value + "\n", "utf8");
}
export async function exists(path: string): Promise<boolean> {
return existsSync(path);
}
export async function isDir(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
export async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
/** 列目录,只返回目录名,跳过以 . 开头的条目与显式排除的名字。 */
export async function listDirs(path: string, exclude: string[] = []): Promise<string[]> {
if (!(await isDir(path))) return [];
const entries = await readdir(path, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !exclude.includes(e.name))
.map((e) => e.name)
.sort();
}
/**
* 拷贝目录树。
*
* 刻意用真拷贝而不是链接(hardlink / junction):分发目录要能被整体搬到别的机器上传,
* 链接会让"目录可整体搬走"这条不变式在某些工具下失效(见 docs/adr/0005)。
*/
export async function copyTree(src: string, dest: string): Promise<void> {
if (!(await exists(src))) return;
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest, { recursive: true, force: true });
}
/** 拷贝单个文件,自动建父目录。 */
export async function copyFileTo(src: string, dest: string): Promise<void> {
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest, { force: true });
}
/** 目录内所有文件的相对路径(以 / 分隔)。 */
export async function walkFiles(root: string, base = root): Promise<string[]> {
if (!(await isDir(base))) return [];
const out: string[] = [];
for (const entry of await readdir(base, { withFileTypes: true })) {
const full = join(base, entry.name);
if (entry.isDirectory()) out.push(...(await walkFiles(root, full)));
else if (entry.isFile()) out.push(relative(root, full).split(sep).join("/"));
}
return out.sort();
}
/** 目录内所有文件的总字节数。 */
export async function dirBytes(root: string): Promise<number> {
if (!(await isDir(root))) return 0;
let total = 0;
for (const f of await walkFiles(root)) total += (await stat(join(root, f))).size;
return total;
}
export async function sizeOf(path: string): Promise<number> {
try {
return (await stat(path)).size;
} catch {
return 0;
}
}
/** 一个长任务的耗时统计与人性化打印。 */
export function mb(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
export function sha1(text: string): string {
return createHash("sha1").update(text).digest("hex");
}
/** 清空目录后重建(用于"分发目录绝不残留脏文件"这条保证)。 */
export async function resetDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true });
await mkdir(path, { recursive: true });
}
/** 单行进度输出,避免几十个分发目录刷屏。 */
export function log(message: string): void {
process.stdout.write(message + "\n");
}