Files
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

234 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`_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