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

+233
View File
@@ -0,0 +1,233 @@
"""`_out/` 的自检:产物是不是真的自洽。
抓取最怕的不是"下不动",是"下了一半还长得像成功":侧车引用的骨架漏了一个、atlas 声明的贴图页
没落地、页名与文件名对不上(运行时就会去请求一个不存在的页)、预设里的 `./…` 路径找不到文件。
所以每次 `fetch` 结束都会跑一遍,`verify` 子命令也能单独跑;**退出码非 0 = 有硬伤**。
形状(一个场景 = 一个可直接搬走的壁纸目录,见 `layout.py`)::
<root>/<游戏>/<页面>/page.json
<root>/<游戏>/<页面>/<场景id>/meta.json | preset.template.json | scene.json
<root>/<游戏>/<页面>/<场景id>/spines/<名>/… scene/… audios/
约定:这里只做"引用闭包 + 文件齐全 + 页名一致 + 版本可读 + 预设路径存在"五件事,不评判画面对不对。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from . import atlas as atlas_mod
from . import layout as layout_mod
__all__ = ["verify_page", "verify_all", "verify_scene_dir", "SUPPORTED_RUNTIME"]
# 本仓库 vendored 的播放器是 spine-ts 4.2 线:4.0/4.1/4.2 的数据都能读,
# 4.3 起把约束并进 root.constraints,4.2 解析器会静默丢掉全部约束——所以要显式报出来。
SUPPORTED_RUNTIME = (4, 2)
_EXT = (".png", ".webp", ".jpg", ".jpeg")
_ID_OK = re.compile(r"^[a-z0-9][a-z0-9_-]*$", re.IGNORECASE)
def _read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _runtime_of(version: str | None) -> tuple[int, int] | None:
if not version:
return None
m = re.match(r"(\d+)\.(\d+)", version)
if not m:
return None
return (int(m.group(1)), int(m.group(2)))
def _is_scene_dir(path: Path) -> bool:
"""场景目录 = 有 `preset.template.json`。判据不能只看目录名——目录名是转义过的场景 id。"""
return (path / "preset.template.json").is_file()
def verify_scene_dir(scene_dir: Path) -> tuple[list[str], dict[str, Any]]:
"""检查一个**场景目录**(= 一份可直接搬走的壁纸目录);返回(问题列表, 统计)。"""
problems: list[str] = []
stats: dict[str, Any] = {"spines": 0, "images": 0, "versions": {}, "bytes": 0}
for name in ("meta.json", "preset.template.json", "scene.json"):
if not (scene_dir / name).is_file():
problems.append(f"缺少 {name}")
try:
scene = _read_json(scene_dir / "scene.json")
except (OSError, json.JSONDecodeError) as exc:
return [*problems, f"scene.json 读不出来:{exc}"], stats
parts = scene.get("parts") or []
if not parts:
problems.append("scene.json 里一个 part 都没有(场景选择可能失败)")
# meta.json:这些字段是构建期 fail-fast 校验的同一批(tools/lib/vault.ts),这里提前红。
meta_file = scene_dir / "meta.json"
if meta_file.is_file():
try:
meta = _read_json(meta_file)
except json.JSONDecodeError as exc:
problems.append(f"meta.json 不是合法 JSON:{exc}")
meta = None
if isinstance(meta, dict):
if meta.get("id") != scene_dir.name:
problems.append(f"meta.json 的 id {meta.get('id')!r} 与目录名 {scene_dir.name!r} 不一致")
if not _ID_OK.match(str(meta.get("id") or "")):
problems.append(f"meta.json 的 id {meta.get('id')!r} 含非法字符(只能是字母数字与 _-)")
for key in ("name", "title", "description"):
if not meta.get(key):
problems.append(f"meta.json 缺 {key}")
audio = meta.get("audio")
if not isinstance(audio, dict) or not isinstance(audio.get("choices"), list):
problems.append("meta.json 的 audio.choices 不是列表")
else:
for index, choice in enumerate(audio["choices"]):
rel = (choice or {}).get("file") if isinstance(choice, dict) else None
if not rel:
problems.append(f"meta.json 的 audio.choices[{index}] 缺 file")
elif not (scene_dir / str(rel).replace("\\", "/").removeprefix("./")).is_file():
problems.append(f"meta.json 声明了音源 {rel},但磁盘上没有这个文件")
preset_file = scene_dir / "preset.template.json"
if preset_file.is_file():
try:
preset = _read_json(preset_file)
except json.JSONDecodeError as exc:
problems.append(f"preset.template.json 不是合法 JSON:{exc}")
preset = None
if isinstance(preset, dict):
scene_config = preset.get("sceneConfig")
spine_config = preset.get("spineConfig")
if bool(scene_config) == bool(spine_config):
problems.append("preset.template.json 必须二选一地写 sceneConfig 或 spineConfig")
# 全是运行时纹理的场景(各页的 scene_ui:drawScene 渲染目标)本来就凑不出 part,
# 那种目录只是"留档不可搬走";但 scene.json 里有该落盘的 part 而预设漏了,是硬伤。
drawable = [
p for p in parts
if p.get("kind") == "spine" or (p.get("kind") == "image" and not p.get("runtime"))
]
if isinstance(scene_config, dict) and not (scene_config.get("parts") or []) and drawable:
problems.append("preset.template.json 的 sceneConfig.parts 是空的,但 scene.json 里有可落盘的 part")
for rel in layout_mod.missing_preset_paths(preset, scene_dir):
problems.append(f"preset.template.json 引用的 {rel} 不在磁盘上")
for part in parts:
kind, pid = part.get("kind"), part.get("id")
if kind == "spine":
stats["spines"] += 1
spine_dir = scene_dir / layout_mod.SPINE_DIR / str(pid)
json_file = spine_dir / f"{pid}.json"
atlas_file = spine_dir / f"{pid}.atlas"
spine_meta = spine_dir / "meta.json"
for path in (json_file, atlas_file, spine_meta):
if not path.exists():
problems.append(f"骨架 {pid} 缺少 {path.name}")
if not atlas_file.exists():
continue
atlas_text = atlas_file.read_text(encoding="utf-8")
info = atlas_mod.parse(atlas_text)
if not info.pages:
problems.append(f"骨架 {pid} 的 atlas 里没有贴图页行")
# 页名**读 atlas 自己声明的**,与落盘文件名逐字比对——按 `<stem>_N` 猜会漏掉多页。
for page_name in info.pages:
if not (spine_dir / page_name).exists():
problems.append(f"骨架 {pid} 缺少贴图页 {page_name}")
if spine_meta.exists():
try:
meta = _read_json(spine_meta)
except json.JSONDecodeError as exc:
problems.append(f"骨架 {pid} 的 meta.json 不是合法 JSON:{exc}")
continue
version = meta.get("spine")
runtime = _runtime_of(version)
if version:
stats["versions"][str(version)] = stats["versions"].get(str(version), 0) + 1
if runtime and runtime > SUPPORTED_RUNTIME:
problems.append(
f"骨架 {pid} 是 {version} 格式,超出 vendored 播放器(4.2 线)能读的范围"
)
elif kind == "image":
if part.get("runtime"):
continue # 由骨架渲染进贴图缓冲的平面没有独立文件,不是缺失
stats["images"] += 1
if layout_mod.image_file(scene_dir, str(pid)) is None:
problems.append(f"几何平面的贴图缺失:scene/{pid}.*")
for path in scene_dir.rglob("*"):
if path.is_file():
stats["bytes"] += path.stat().st_size
return problems, stats
def verify_page(page_dir: Path) -> tuple[list[str], dict[str, Any]]:
"""检查一个页面目录(它下面每个场景目录各是一份壁纸目录)。"""
problems: list[str] = []
stats: dict[str, Any] = {"sceneDirs": 0, "spines": 0, "images": 0, "versions": {}, "bytes": 0}
page_file = page_dir / "page.json"
if not page_file.exists():
return [f"缺少 page.json:{page_dir}"], stats
try:
_read_json(page_file)
except json.JSONDecodeError as exc:
return [f"page.json 不是合法 JSON:{exc}"], stats
# 旧形状(一页一个场景)的残留:页面根的 scene.json / spine/ / scene/ 现在都不该存在。
for legacy in ("scene.json", "spine", "scene"):
if (page_dir / legacy).exists():
problems.append(f"页面根上还留着旧形状的 {legacy}(新形状里它属于每个场景目录)")
scenes = sorted(p for p in page_dir.iterdir() if p.is_dir() and _is_scene_dir(p))
strays = sorted(
p.name for p in page_dir.iterdir() if p.is_dir() and not _is_scene_dir(p) and p.name not in ("spine", "scene")
)
if not scenes:
problems.append("页面下没有任何场景目录(先跑 fetch)")
for name in strays:
problems.append(f"页面下的 {name}/ 不是场景目录(缺 preset.template.json)")
for scene_dir in scenes:
scene_problems, scene_stats = verify_scene_dir(scene_dir)
problems.extend(f"[{scene_dir.name}] {p}" for p in scene_problems)
stats["sceneDirs"] += 1
stats["spines"] += scene_stats["spines"]
stats["images"] += scene_stats["images"]
stats["bytes"] += scene_stats["bytes"]
for version, count in scene_stats["versions"].items():
stats["versions"][version] = stats["versions"].get(version, 0) + count
return problems, stats
def verify_all(root: Path, pages: list[str] | None = None) -> tuple[list[str], dict[str, Any]]:
"""检查 `_out/` 下的全部(或指定)页面。"""
problems: list[str] = []
total: dict[str, Any] = {"pages": 0, "sceneDirs": 0, "spines": 0, "images": 0, "bytes": 0, "versions": {}}
if not root.exists():
return [f"产物目录不存在:{root}"], total
candidates = []
for game_dir in sorted(p for p in root.iterdir() if p.is_dir()):
for page_dir in sorted(p for p in game_dir.iterdir() if p.is_dir()):
if pages and page_dir.name not in pages:
continue
candidates.append(page_dir)
if not candidates:
problems.append(f"没有任何页面产物:{root}")
for page_dir in candidates:
page_problems, stats = verify_page(page_dir)
problems.extend(f"[{page_dir.parent.name}/{page_dir.name}] {p}" for p in page_problems)
total["pages"] += 1
total["sceneDirs"] += stats["sceneDirs"]
total["spines"] += stats["spines"]
total["images"] += stats["images"]
total["bytes"] += stats["bytes"]
for version, count in stats["versions"].items():
total["versions"][version] = total["versions"].get(version, 0) + count
return problems, total