Files
gplan-2/tools/path_check.py
T
DKChan 32fdf957fd refactor(代码术语): 卡① flag→seal 全族换代+game/ 禁用词清扫(迭代 31 卡①)
标识符面:flag_*→seal_ 族(含 Godot 信号 flag_judged→seal_judged 与连接/回调同名、
FACTS 两键 flags_count→seals_count/dug_under_flag→dug_under_seal 全链、
.tres 键名与 schema .gd 同批同步);显式豁免零改动=size_flags_*(Godot 布局属性)+_fatal_flag。
中文面:金币→GP(金币袋→GP 袋)/插旗→封印/雷格→危险格/惩罚格→危险格/旗→封印·封印印记族,
game/ 内 *.gd/.tres/.md/.json 四类禁用词与 flag 双双零命中(V1/V4)。
守卫字面量不动(被检查的禁字不是使用):smoke_all V21 零「雷/旗」检查面、
ui_font_asset_test 字形集攻/雷/旗/封 保持原断言逻辑。
代拍板:板面单字槽 glyph「旗」→「封」(禁字纪律+单字位宽,消费面四处同名随动)。
素材换代(2026-10-18 用户裁案甲):assets/art/board/flag_seal.png→seal_mark.png(素材不入 git,
盘上改名+导入重刷),代码两处 res:// 字符串+COL_FLAG/CURSOR_FLAG 常量同步,
闸件四处随批(run_all_gates.sh/render_gate.sh/golden_baseline_refresh.sh/path_check.py)。
anchor.json 键名手改,重锚与改前改后 diff 随本卡收口(同裁案甲)。

基线读数(改前,主区 ed2dfaf6):冒烟 1151 检查 0 失败/单元 34 套全绿/探针 --click
存量红仅 screen_flow_probe(user:// 断点污染在册已知项)。
改后同批读数(本 worktree):冒烟 1151 检查 0 失败/探针 --click 同一存量红、其余全绿。
2026-10-19 02:52:38 +08:00

240 lines
11 KiB
Python
Raw 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B3 路径核对器(圣经 §8.3② 挂点实现 · 立绘定档批 2026-10-07)。
职责:扫描 asset-path-map.md 声明的全部路径 + 代码侧素材常量,
报「悬空(声明存在但盘上无)/ 退役未清(仍引用已退役命名)」。
为什么需要(2026-10-06 实证三处悬空):素材不入 git,路径错了不会报错,
只会在实机静默回退(UiKit.load_tex 的 exists 守卫)⇒ 肉眼难发现。
三层校验:
L1 目录级 asset-path-map §1 声明的路径存在性(candidates 按需创建豁免)
L2 文件级 §3 game/assets/art 语义子目录现役清单逐件存在性
L3 代码级 grep 代码里的 res://assets/art 引用,核对盘上是否存在
+ 检出已知退役命名(board_floor/board_wall/pillar_dungeon_*)
用法:
python tools/path_check.py [--repo <仓库根>] [--code <代码根>]
退出码: 0 通过 / 1 有红灯
"""
import os
import re
import sys
# asset-path-map §1 目录分工总表(逐条对应文档,勿复写第二份事实源)
DECLARED_DIRS = [
# (路径, 角色, 按需创建豁免)
("design/art/candidates", "设计侧源档(档案库,游戏禁引用)", True),
("game/assets/art", "运行时素材唯一落地处", False),
("game/assets/ui/icons", "UI 图标(唯一入 git 素材目录)", False),
("artwork/mockups", "混合样张/评审产物", False),
]
# asset-path-map §3 game/assets/art 语义子目录
DECLARED_SUBDIRS = ["character", "fx", "board", "ui"]
# §3 现役清单(文件名按 §8.3 域键式)
ACTIVE_FILES = [
"character/character_hero_idle.png",
"character/character_hero_walk.png",
"character/character_hero_portrait.png",
"fx/shovel_swing_f0.png",
"fx/shovel_swing_f1.png",
"fx/shovel_swing_f2.png",
"board/seal_mark.png",
]
# 立绘八敌按数据表键生成(键集=STANDEE_TIER_H,见 standee_asset_check)
ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider",
"goblin", "hook_horror", "ogre_chief")
ACTIVE_FILES += ["character/character_enemy_%s.png" % k for k in ENEMY_KEYS]
# asset-path-map v3.9 已退役命名(检出即红)
RETIRED_PATTERNS = [
(re.compile(r"board_floor"), "board_floor 命名已退役(→terrain_floor_<board_type>_<n>)"),
(re.compile(r"board_wall"), "board_wall 命名已退役"),
(re.compile(r"pillar_dungeon"), "pillar_dungeon_* 命名已退役"),
(re.compile(r"production/mockups"), "production/mockups 为废路径(→artwork/mockups)"),
]
# 路径末尾要允许 printf 转换符(%s/%d/%0Nd)——否则正则会在 '%' 处截断,
# 拿到 "res://assets/art/board/terrain_" 这种半截串,既不像模板也不像真实
# 路径,两边都判错。初版即栽在此处(2 处假悬空),现让 %s/%d 一并吃掉。
RES_RE = re.compile(r"res://assets/art/[A-Za-z0-9_/\.\-%]+")
# ⚠ 路径模板(printf 式)不是可解析的真实路径——常量里存的是
# "res://assets/art/board/terrain_%s_%d.png" 这类格式串,盘上当然没有
# 字面同名文件。初版未识别⇒ 假悬空。识别到 % 转换符即跳过(模板有独立的
# 运行时守卫与 test 覆盖,不在本器执法面)。
FMT_MARKERS = ("%s", "%d", "%0", "{")
def is_path_template(ref):
return any(m in ref for m in FMT_MARKERS)
def main():
argv = sys.argv[1:]
repo = "."
code = "game"
if "--repo" in argv:
repo = argv[argv.index("--repo") + 1]
if "--code" in argv:
code = argv[argv.index("--code") + 1]
problems = []
notes = []
# ⚠ worktree 素材环境判定(素材不入 git ⇒ worktree 素材靠人工拷贝同步,
# 常见三种态,须区分否则满屏假红):
# full —— 主区形态,art 下素材齐全,全量校验
# partial —— worktree 常态:character/ 被拷进来了(跑测试三件套惯例),
# fx/board/ui 没拷 ⇒ 缺的那些是「未同步」不是「悬空」
# empty —— art 下 PNG 总数为 0,素材整体未同步
# 只有 full 态才把缺失判红灯;partial/empty 判 SKIP 并提示同步。
artdir = os.path.join(repo, "game", "assets", "art")
present = [s for s in DECLARED_SUBDIRS
if os.path.isdir(os.path.join(artdir, s))]
png_total = sum(1 for dp, _, ns in os.walk(artdir)
for n in ns if n.lower().endswith(".png"))
if len(present) == len(DECLARED_SUBDIRS):
env = "full"
elif png_total == 0:
env = "empty"
else:
env = "partial"
synced = env != "full"
if synced:
notes.append("素材环境 = **%s**(子目录就位 %d/%d,PNG 共 %d)—— "
"worktree 素材靠人工拷贝同步,缺项属未同步非悬空"
% ({"partial": "部分同步", "empty": "空"}[env],
len(present), len(DECLARED_SUBDIRS), png_total))
notes.append("缺失子目录:%s(如需完整校验请对**主区**跑本器)"
% ("、".join(s for s in DECLARED_SUBDIRS
if s not in present) or "无"))
else:
notes.append("素材环境 = 完整(主区形态),全量校验")
print("=== L1 目录级(asset-path-map §1)===")
for rel, role, exempt in DECLARED_DIRS:
p = os.path.join(repo, rel)
ok = os.path.isdir(p)
if ok:
print("OK %-30s %s" % (rel, role))
elif exempt:
print("SKIP %-30s 按需创建豁免(文档已注非作废)" % rel)
elif synced and rel.startswith(("game/", "artwork/")):
print("SKIP %-30s 素材未同步豁免 —— %s" % (rel, role))
else:
print("FAIL %-30s 悬空:声明存在但盘上无 —— %s" % (rel, role))
problems.append("目录悬空: " + rel)
print("\n=== L2 文件级(asset-path-map §3 现役清单)===")
for rel in ACTIVE_FILES:
p = os.path.join(repo, "game", "assets", "art", rel)
sub = rel.split("/")[0]
if os.path.isfile(p):
print("OK %s" % rel)
elif synced:
print("SKIP %-44s 素材未同步豁免" % rel)
else:
print("FAIL %-44s 悬空(现役清单声明但盘上无)" % rel)
problems.append("文件悬空: " + rel)
for sub in DECLARED_SUBDIRS:
p = os.path.join(artdir, sub)
if not os.path.isdir(p):
if synced:
print("SKIP 子目录 %-40s 素材未同步豁免" % sub)
else:
print("FAIL 子目录 %-40s 悬空" % sub)
problems.append("子目录悬空: " + sub)
print("\n=== L3 代码级 res:// 引用核对 ===")
# ⚠ 口径:Godot 的 res:// 根 = 项目目录 game/(project.godot 所在处),
# 不是仓库根。所以 res://assets/art/x.png → <repo>/game/assets/art/x.png。
# 2026-10-07 初版误按仓库根解析 ⇒ 全部引用假悬空,已修。
refs = {}
for dirpath, dirnames, names in os.walk(os.path.join(repo, code)):
dirnames[:] = [d for d in dirnames
if d not in (".git", ".godot", "import", "node_modules")]
for fn in names:
if not fn.endswith((".gd", ".tscn", ".tres")):
continue
fp = os.path.join(dirpath, fn)
try:
txt = open(fp, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
for m in RES_RE.findall(txt):
refs.setdefault(m, []).append(os.path.relpath(fp, repo))
if not refs:
print("(代码侧无 res://assets/art 硬引用——符合 §2 第4步禁 preload 纪律)")
templates = []
for ref, where in sorted(refs.items()):
if is_path_template(ref):
templates.append((ref, where))
continue
p = os.path.join(repo, "game", *ref[len("res://"):].split("/"))
if os.path.exists(p):
print("OK %-52s ← %d 处" % (ref, len(where)))
elif synced:
print("SKIP %-52s 无素材环境豁免 ← %s" % (ref, where[0]))
else:
print("FAIL %-52s 悬空 ← %s" % (ref, ", ".join(where[:3])))
problems.append("代码引用悬空: " + ref)
if templates:
print("SKIP 路径模板 %d 条(printf 格式串,非字面路径;运行时由守卫兜底)"
% len(templates))
for ref, where in templates:
print(" · %-46s ← %s" % (ref, where[0]))
print("\n=== 退役命名扫描 ===")
# ⚠ 口径纪律:退役命名**只在代码与素材清单里是违规**;文档的版本历史与叙述性文件
# 提到这些名字属正常,不是违规。故扫描面只含:代码(.gd/.tscn) + 素材清单
# (design/art/*.md,且文件名不含「版本历史」),其余一律不扫;
# .git/.workbuddy/.zcode/archive 在目录层就排除。
# 2026-10-07 初版全仓扫 .md 曾把历史记述全判成违规,故收窄到上述两面(当日还额外跳过
# 根目录会话状态文件,该文件已于 2026-10-18 用户裁废除,跳过项一并删除)。
hits = 0
scan_exts = (".gd", ".tscn")
for dirpath, dirnames, names in os.walk(repo):
dirnames[:] = [d for d in dirnames
if d not in (".git", ".godot", "archive", "node_modules",
".workbuddy", ".zcode")]
for fn in names:
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, repo)
if any(s in rel for s in (".workbuddy", ".zcode")):
continue
is_code = fn.endswith(scan_exts)
is_manifest = (fn.endswith(".md")
and os.sep + "art" + os.sep in fp
and "版本历史" not in fn)
if not (is_code or is_manifest):
continue
try:
txt = open(fp, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
# 叙述性豁免:命中行若同时含「退役/已废/更正/改判」等记述词,
# 视为「在讲这段历史」而非「仍在用这个名字」
for rx, why in RETIRED_PATTERNS:
for line in txt.splitlines():
if rx.search(line) and not any(
w in line for w in ("退役", "已废", "更正", "改判",
"原写", "原名", "作废")):
print("FAIL %-52s %s" % (rel, why))
print(" └─ %s" % line.strip()[:90])
problems.append("退役命名: " + rel)
hits += 1
break
if not hits:
print("OK 无退役命名残留(代码 + 素材清单;叙述性历史记述已豁免)")
for nt in notes:
print("\n注:%s" % nt)
print("\n路径机检完成:%d 处问题" % len(problems))
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())