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

@@ -0,0 +1,77 @@
"""补救:按**当前实际布局**重写各档壁纸预设里的资源路径。
背景:migrate-walls.py 的映射键在 Windows 下是反斜杠,而预设里是 `./effects/x.json` 这种正斜杠,
查表全落空 → 文件搬了、预设没改。这里不靠任何映射,直接**在壁纸目录里找**该文件现在在哪:
./effects/<名>.json → spines/<名>/<名>.json
./images/<…> → spines/<骨架>/<文件>(若该文件是某 atlas 声明的页或骨架自己的贴图)
否则 → scene/<文件>
./images/scene/<文件> → scene/<文件>
每个改写都要求目标**真实存在**,否则报错而不是写一个坏路径。
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WALLS = ROOT / "wallpapers"
APPLY = "--apply" in sys.argv
PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
def atlas_pages(text: str) -> list[str]:
return [l.strip() for l in text.splitlines() if l.strip() and ":" not in l.strip() and PAGE_EXT.search(l.strip())]
def owner_of(wall: Path, basename: str) -> str | None:
"""这个文件现在归哪个目录(spines/<名> 还是 scene)。"""
for atlas in (wall / "spines").rglob("*.atlas"):
if basename in atlas_pages(atlas.read_text(encoding="utf-8")):
return f"spines/{atlas.parent.name}"
for spine_dir in (wall / "spines").iterdir() if (wall / "spines").is_dir() else []:
if (spine_dir / basename).exists():
return f"spines/{spine_dir.name}"
if (wall / "scene" / basename).exists():
return "scene"
return None
failed: list[str] = []
for wall in sorted(p.parent for p in WALLS.rglob("preset.template.json")):
preset_path = wall / "preset.template.json"
preset = json.loads(preset_path.read_text(encoding="utf-8"))
changed: list[str] = []
def rewrite(value: object) -> object:
if isinstance(value, str) and value.startswith("./") and "/" in value[2:]:
basename = value.rsplit("/", 1)[-1]
owner = owner_of(wall, basename)
if owner and value != f"./{owner}/{basename}":
changed.append(f"{value} → ./{owner}/{basename}")
return f"./{owner}/{basename}"
if owner is None:
failed.append(f"{wall.relative_to(ROOT)}: {value} 在磁盘上找不到")
if isinstance(value, dict):
return {k: rewrite(v) for k, v in value.items()}
if isinstance(value, list):
return [rewrite(v) for v in value]
return value
new_preset = rewrite(preset)
print(f"\n== {wall.relative_to(ROOT)}")
for c in changed:
print(" " + c)
if APPLY and changed:
preset_path.write_text(json.dumps(new_preset, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8")
if failed:
print("\n✗ 以下路径找不到对应文件(未改写):")
for f in failed:
print(" " + f)
sys.exit(1)
print(f"\n{'已重写' if APPLY else '将重写'} {len(changed) if False else ''}{'(dry-run)' if not APPLY else ''}")