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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user