把项目从「手写 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 处引用自包含。
234 lines
11 KiB
Python
234 lines
11 KiB
Python
"""`_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
|