"""`_out/` 的自检:产物是不是真的自洽。 抓取最怕的不是"下不动",是"下了一半还长得像成功":侧车引用的骨架漏了一个、atlas 声明的贴图页 没落地、页名与文件名对不上(运行时就会去请求一个不存在的页)、预设里的 `./…` 路径找不到文件。 所以每次 `fetch` 结束都会跑一遍,`verify` 子命令也能单独跑;**退出码非 0 = 有硬伤**。 形状(一个场景 = 一个可直接搬走的壁纸目录,见 `layout.py`):: /<游戏>/<页面>/page.json /<游戏>/<页面>/<场景id>/meta.json | preset.template.json | scene.json /<游戏>/<页面>/<场景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 自己声明的**,与落盘文件名逐字比对——按 `_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