Merge branch 'master' into art-standee-20261006

# Conflicts:
#	design/art/art-bible.md
This commit is contained in:
DKChan committed 2026-10-17 17:31:22 +08:00
commit 4a65f9eb6d
428 files changed
+25789 -32790

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""audit_active_state.py — 根目录 active.md 体量闸(doc-conventions §10 的机械面)
状态文件只记当下:条目只增不减就是腐烂。本脚本查五条机械可判的膨胀签名——
状态记录条目数超上限、快照节行数超上限、条目编号重复、快照整行自我重复、
指向中间文档(design/qa、design/research)的死指针。
退出码:0=全绿;1=有红(逐条打印差额,供收口批据此删条目)。
用法:python tools/audit_active_state.py [--file PATH] [--quiet]
"""
import argparse
import os
import re
import sys
ENTRY_LIMIT = 30 # §10:状态记录超过 30 条必须清
SNAPSHOT_LIMIT = 10 # §10:当前状态快照 ≤10 行
INTERMEDIATE_PREFIX = ("design/qa/", "design/research/")
ENTRY_RE = re.compile(r"^- \*\*(\d+)( 续)?")
BULLET_RE = re.compile(r"^- .{20,}")
PATH_RE = re.compile(r"design/(?:qa|research)/[^\s)`,,。)]+?\.md")
def split_sections(text):
"""按二级标题切段;标题前的内容归入 '' 键(文件头元信息,本闸不判)。"""
out, key = {}, ""
for line in text.splitlines():
if line.startswith("## "):
key = line[3:].strip()
out[key] = []
continue
out.setdefault(key, []).append(line)
return out
def find_key(sections, prefix):
for name in sections:
if name.startswith(prefix):
return name
return None
def audit(path, repo):
with open(path, encoding="utf-8") as f:
sections = split_sections(f.read())
reds = []
snap_key = find_key(sections, "当前状态快照")
rec_key = find_key(sections, "状态记录")
if snap_key is None:
reds.append("A0 快照节缺失:状态文件必须含「当前状态快照」节")
if rec_key is None:
reds.append("A0 状态记录节缺失:状态文件必须含「状态记录」节")
snap = [ln for ln in sections.get(snap_key, []) if BULLET_RE.match(ln)]
if len(snap) > SNAPSHOT_LIMIT:
reds.append("A1 快照节 %d 条,超上限 %d——先删被新一轮取代的旧行,别只往顶部加"
% (len(snap), SNAPSHOT_LIMIT))
seen = {}
for ln in snap:
seen[ln] = seen.get(ln, 0) + 1
dups = [ln for ln, n in seen.items() if n > 1]
if dups:
reds.append("A2 快照节有 %d 行整行自我重复(换代不彻底的签名):%s"
% (len(dups), dups[0][:60] + " …"))
nums, conts = [], []
for ln in sections.get(rec_key, []):
m = ENTRY_RE.match(ln)
if m:
(conts if m.group(2) else nums).append(int(m.group(1)))
if len(nums) > ENTRY_LIMIT:
reds.append("A3 状态记录 %d 条,超硬上限 %d——按 §10 判据删除已完结批次条目"
% (len(nums), ENTRY_LIMIT))
repeated = sorted({n for n in nums if nums.count(n) > 1})
if repeated:
reds.append("A4 条目编号重复(并行侧写撞号):%s"
% "、".join(str(n) for n in repeated))
body = "\n".join(sections.get(snap_key, []) + sections.get(rec_key, [])
+ sections.get(find_key(sections, "未决面"), []))
dead = sorted({p for p in PATH_RE.findall(body)
if not os.path.exists(os.path.join(repo, p.replace("`", "")))})
if dead:
reds.append("A5 指向中间文档的死指针 %d 处(文件已按 §11 退役,条目却还在引):%s"
% (len(dead), "、".join(dead)))
return reds, len(nums), len(snap)
def main():
ap = argparse.ArgumentParser(description="active.md 体量闸")
ap.add_argument("--file", default=None, help="待审文件(默认为本仓库根目录 active.md)")
ap.add_argument("--quiet", action="store_true", help="只输出结论行")
args = ap.parse_args()
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = args.file or os.path.join(repo, "active.md")
if not os.path.exists(path):
print("[active.md 体量闸] 找不到文件:%s" % path)
return 1
reds, entries, snap = audit(path, repo)
if reds:
print("[active.md 体量闸] FAIL:%d 项——状态记录 %d 条 / 快照 %d 行" % (len(reds), entries, snap))
for r in reds:
print(" " + r)
return 1
if not args.quiet:
print("[active.md 体量闸] PASS:状态记录 %d 条 / 快照 %d 行,编号唯一、指针全活" % (entries, snap))
return 0
if __name__ == "__main__":
try:
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""audit_file_headers.py — 文件头机器闸(code-conventions §1.4.1)。
文件头 = .gd 文件顶部连续注释区:连续的空行或 # 注释行,首个非注释代码行止。
本脚本只实现机械可判子集(两类白名单的人判面见规范正文):
R0 载体:头注非空时首行必须以 `##` 文档注释开头(清偿批落地起生效);
R1 行数上限:头注注释行(非空)> 8 行;
R2 日期:头注出现日期模式(2026-09-12 / 2026年9月 一类)——日期只属于 git log 与迭代报告;
R3 变更叙事词:「迭代N / 本迭代」、批次叙事复合词(本批/拆件批/改造批……)、「迁移到/改名为」
一类变更经过。清单从紧防误伤:「结算批」等玩法机制词不在黑名单;
新失败模式先补本脚本规则再修码(static_scan 纪律)。
扫描域 = game/core + game/data + game/presentation(tests/smoke 豁免,同 audit_type_hints 口径)。
退出码:0=归零,1=有存量(逐条列 file:line)。用法:python tools/audit_file_headers.py
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCAN_DIRS = ["game/core", "game/data", "game/presentation"]
MAX_HEADER_LINES = 8
DATE_PAT = re.compile(r"\d{4}\s*[-/.年]\s*\d{1,2}")
NARRATIVE_PATS = [
(re.compile(r"迭代\s*\d|本迭代"), "变更叙事「迭代N」"),
(re.compile(
r"本批|拆件批|改造批|重构批|实装批|清偿批|落地批|收口批|收尾批|转正批|对齐批|拆分批|换代批|升级批|随批"),
"变更叙事「XX批」"),
(re.compile(r"迁移到|迁至|改名为|更名为|原名为|曾用名"), "变更经过叙述"),
]
def header_lines(text):
"""返回文件头注释区的注释行 list[(lineno, line)],首个非注释非空行止;空行不计数。"""
rows = []
for lineno, line in enumerate(text.splitlines(), start=1):
stripped = line.lstrip("\ufeff").strip()
if stripped == "":
continue
if stripped.startswith("#"):
rows.append((lineno, line))
else:
break
return rows
def audit_path(path):
hits = []
text = path.read_text(encoding="utf-8", errors="replace")
rows = header_lines(text)
if rows and not rows[0][1].lstrip().startswith("##"):
hits.append((rows[0][0], "R0 头注必须以 ## 文档注释开头"))
if len(rows) > MAX_HEADER_LINES:
hits.append((rows[0][0], "R1 头注 %d 行 > 上限 %d 行(区间 %d-%d 行)"
% (len(rows), MAX_HEADER_LINES, rows[0][0], rows[-1][0])))
for lineno, line in rows:
if DATE_PAT.search(line):
hits.append((lineno, "R2 头注日期"))
for pat, label in NARRATIVE_PATS:
if pat.search(line):
hits.append((lineno, "R3 " + label))
return hits
def main():
violations = 0
for d in SCAN_DIRS:
base = ROOT / d
if not base.is_dir():
continue
for path in sorted(base.rglob("*.gd")):
for lineno, msg in audit_path(path):
print("%s:%d: %s" % (path.relative_to(ROOT), lineno, msg))
violations += 1
if violations:
print("[audit_file_headers] FAIL: 文件头存量 %d 处(存量按 code-conventions §1.4.1 白名单清偿)" % violations)
return 1
print("[audit_file_headers] ok: 头注 ≤8 行且零黑名单命中")
return 0
if __name__ == "__main__":
sys.exit(main())
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""audit_type_hints.py — 迭代27 T5 类型闸机器审计(code-conventions §4 语言纪律的机器闸)。
产品码 = game/core + game/data + game/presentation(smoke/tests 豁免——测量面容器直构是惯例)。
三条规则:
R1 无类型容器声明:显式 Dictionary/Array 非参数化类型注解(审计 §2.2「73 处」同口径——
`:= {}` 推断形态属类型推断合法面不算债)。
R2 有值返回缺返回类型:func 无 `->` 且函数体内存在 `return <非空值>`(纯副作用 -> void
不属本闸危害域,归静态写法面)。
R3 参数缺类型:func 声明参数无 `: Type`(_init 豁免——构造注入面形态自由)。
白名单:行内 `# type-hint: ignore` 豁免该行(V7 显式白名单通道)。
退出码:0=归零,1=有存量(逐条列 file:line)。用法:python tools/audit_type_hints.py
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCAN_DIRS = ["game/core", "game/data", "game/presentation"]
R1A_UNPARAM = re.compile(r"\bvar\s+\w+\s*:\s*(Dictionary|Array)\b(?!\[)")
R1B_LITERAL = re.compile(r"^\s*var\s+\w+\s*(:?=)\s*[\{\[]")
R1B_TYPED = re.compile(r"^\s*var\s+\w+\s*:\s*[\w\[\]]+")
FUNC_DECL = re.compile(r"^(\s*)func\s+(_?\w+)\s*\((.*)\)\s*(->\s*.+)?\s*(#.*)?$")
PARAM_SPLIT = re.compile(r",(?![^(\[]*[)\]])")
RETURN_VALUE = re.compile(r"^\s*return\s+\S")
def strip_strings(line):
line = re.sub(r'"[^"]*"', '""', line)
line = re.sub(r"'[^']*'", "''", line)
if line.lstrip().startswith("#"):
return ""
return line.split("#")[0]
def audit_file(path):
hits = []
lines = path.read_text(encoding="utf-8").split("\n")
for idx, raw in enumerate(lines):
if "# type-hint: ignore" in raw:
continue
code = strip_strings(raw)
# R1a 显式非参数化容器类型
if R1A_UNPARAM.search(code):
hits.append((idx + 1, "R1", raw.strip()[:90]))
continue
fm = FUNC_DECL.match(code)
if not fm:
continue
indent, fname, params, ret = fm.group(1), fm.group(2), fm.group(3), fm.group(4)
# R3 参数缺类型(_init 豁免)
if fname != "_init" and params.strip():
for prm in PARAM_SPLIT.split(params):
prm = prm.strip()
if prm and ":" not in prm:
hits.append((idx + 1, "R3", "%s(参数 %s)" % (raw.strip()[:80], prm.split("=")[0].strip())))
# R2 有值返回缺返回类型(函数体 = 缩进更深的后续连续块)
if not ret and fname != "_init":
body = []
j = idx + 1
while j < len(lines):
l = lines[j]
if l.strip() == "":
j += 1
continue
if len(l) - len(l.lstrip("\t")) > len(indent):
body.append(strip_strings(l))
j += 1
continue
break
if any(RETURN_VALUE.match(b) for b in body):
hits.append((idx + 1, "R2", raw.strip()[:90]))
return hits
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
total = 0
for d in SCAN_DIRS:
for p in sorted((ROOT / d).rglob("*.gd")):
for line, rule, text in audit_file(p):
print("[%s] %s:%d: %s" % (rule, p.relative_to(ROOT).as_posix(), line, text))
total += 1
if total:
print("[audit_type_hints] FAIL——存量 %d 处(R1 无类型容器/R2 有值返回缺类型/R3 参数缺类型)" % total)
return 1
print("[audit_type_hints] PASS——产品码类型闸归零(R1/R2/R3)")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
r"""doc_link_audit.py — design 文档链接健康检查
扫 design/gdd/、design/registry/、design/ux/ 下的 .md 与 .yaml,校验两类引用:
扫 design/gdd/、design/registry/ 下的 .md 与 .yaml,校验两类引用:
(a) markdown 链接 [文字](路径[#锚点]) —— 一律相对"链接所在文件自身目录"解析(标准
markdown 语义),跨目录用 ../。排除图片 ![...];目标里混入全角标点(偶发把全角括号
写进目标)时在首个全角标点处截断再校验。
@@ -23,7 +23,7 @@ r"""doc_link_audit.py — design 文档链接健康检查
python tools/doc_link_audit.py <file.md> ... # 只扫指定文件
python tools/doc_link_audit.py --json # 机器可读
口径:只扫 design/gdd/ design/registry/ design/ux/——design/art、design/research
口径:只扫 design/gdd/ design/registry/——design/art、design/research
(历史素材/提案)不在扫描域,但作为链接目标被校验。排除 ``` 围栏代码块与 ; 开头注释行。
"""
import argparse
@@ -32,7 +32,7 @@ import re
import sys
from pathlib import Path
DEFAULT_DIRS = ["design/gdd", "design/registry", "design/ux"]
DEFAULT_DIRS = ["design/gdd", "design/registry"]
PROTO_PREFIX = "game"
ALLOWED_EXTS = ("md", "yaml", "yml", "tres", "gd", "py", "json", "csv")
+3
View File
@@ -0,0 +1,3 @@
# 已知死链清单(doc_link_audit --known 用)——每行 <文件相对路径>|<目标>,命中的归"已知"类不算失败。
# 登记纪律:只收「历史记载/版本行中的已退役件引用」类(历史成文不可动先例);活性指路句死链必须修文不进本清单。
design/gdd/systems/表现与支撑/数据配置.md|smoke/config_fatal_probe.gd
+78 -5
View File
@@ -13,7 +13,7 @@ r"""doc_term_audit.py — GDD 退役术语扫描(半自动)
python tools/doc_term_audit.py <file.md> ... # 只扫指定文件
python tools/doc_term_audit.py --json # 机器可读
口径:只扫 design/gdd/ design/registry/ design/ux/——design/archive、design/research
口径:只扫 design/gdd/ design/registry/——design/research
(历史提案)、docs/(样例)与 production/ 不在扫描域;词表扩充 = 改 RETIRED_TERMS。
"""
import argparse
@@ -43,9 +43,78 @@ RETIRED_TERMS = [
("道具窗口|可选类道具|已消耗未生效|使用被拒|窗口打开锚行|窗口跳过锚行", "2026-09-15", "(随可选类道具整体删除)", "格面资源批"),
("雾格", "2026-09-14", "(雾格移除)", "不做清单"),
("兄弟精", "2026-09-26", "L2 熊地精(bugbear 复用)", "变体名未经批准退役批——用户质问查证后全仓清除"),
# —— 用语规范(2026-10-15 用户令):权威=design/gdd/术语表.md「用语规范」A 表,机器闸吸收 ——
# 注意:「随批」「台账」两词本身是上方行级白名单信号(行内出现即归留档类),对其扫描会自豁免,
# 故不列入;「收口」「在飞」等照常扫描。术语表「用语规范」节已加入节级豁免(词表自身不命中)。
("对标", "2026-10-15", "参照", "用语规范 A 表(STC 1.7)"),
("若干", "2026-10-15", "写具体数值或写明数值出处", "用语规范 A 表(STC 5.4)"),
("不得", "2026-10-15", "禁止", "用语规范 A 表(STC 词表)"),
("报错", "2026-10-15", "名词用错误;动词用返回错误/显示错误", "用语规范 A 表(STC 词表)"),
(r"\bbuff\b", "2026-10-15", "增益", "用语规范 A 表(STC 1.1)"),
("收口", "2026-10-15", "完成/合并进 master", "用语规范 A 表(doc-conventions 第 4 节)"),
("在飞", "2026-10-15", "正在执行中", "用语规范 A 表(doc-conventions 第 4 节)"),
("对拍", "2026-10-15", "与基线数据逐项比对", "用语规范 A 表(doc-conventions 第 4 节)"),
("销账|销号", "2026-10-15", "注销", "用语规范 A 表(doc-conventions 第 4 节)"),
("落文", "2026-10-15", "写入文档/已写入", "用语规范 A 表(doc-conventions 第 4 节同族)"),
]
DEFAULT_DIRS = ["design/gdd", "design/registry", "design/ux"]
DEFAULT_DIRS = ["design/gdd", "design/registry"]
# 用语规范 A 表补充词专项补扫:随批/台账 两词本身是上方行级白名单信号(行内出现即归
# 留档类),主扫描对它们自豁免;本通道只做版本历史节外的专项报告,供后续触碰批清偿。
SUPPLEMENT_TERMS = [
("随批", "2026-10-15", "顺带", "用语规范 A 表(doc-conventions 第 4 节)"),
("台账", "2026-10-15", "状态记录", "用语规范 A 表(doc-conventions 第 4 节)"),
]
def scan_supplement(paths):
terms = [(re.compile(t), date, repl, note) for t, date, repl, note in SUPPLEMENT_TERMS]
findings = []
for p in paths:
text = Path(p).read_text(encoding="utf-8").replace("\r\n", "\n")
in_version_section = False
for lineno, line in enumerate(text.split("\n"), 1):
if SECTION_RE.match(line):
in_version_section = True
hits = [(m.group(0), t) for t in terms for m in [t[0].search(line)] if m]
if not hits or in_version_section or COMMENT_RE.match(line):
continue
findings.append({
"term": "、".join(sorted({h[0] for h in hits})),
"file": str(p).replace("\\", "/"),
"line": lineno,
"klass": "疑似现行",
"text": line.strip()[:160],
"retired": hits[0][1][1],
"replacement": hits[0][1][2],
})
return findings
# 词级语境豁免:某词退役的只是特定机制语境,另一现行机制合法沿用同一词时,
# 命中行含豁免语境信号即不算退役词命中(不进疑似、不进留档计数)。
# 充能:挖掘期/战斗段充能退役(2026-09-19),但秘宝主动件「每冒险充能」(同调件
# 使用次数账,迭代 11 起在役)系现行机制共用该词——秘宝/同调/主动件等语境不算命中。
TERM_CONTEXT_EXEMPT = {
"充能": re.compile(r"秘宝|同调|主动件|每冒险|冒险开始|充能账|充能维护|attunement"
r"|incense_stick|sky_eye|reveal_tiles|战斗级充能|关卡级充能|每战一次|每关一次"),
# buff:反引号行内代码(字段/键名)原样保留(STC 6.2),只查散文用词
r"\bbuff\b": re.compile(r"`[^`]*buff[^`]*`"),
# 否定式/反照式引用:正文在说明「该类别已退役/不做/不存在」,退役词出现在句中是
# 陈述本身的一部分,替换掉反而毁掉句意(2026-10-15 用户令「统一替换」问询的判定)。
"道具窗口|可选类道具|已消耗未生效|使用被拒|窗口打开锚行|窗口跳过锚行": re.compile(
r"无可选类|不存在|已随|整体删除|现行池内|不进背包"),
"雾格": re.compile(r"不做清单|战争迷雾类机制"),
# heavy_multiplier 字段与 ambusher 敌人行同于 2026-09-20 八型批整体退役(enemy_table.gd
# 与 enemy.tres 均零命中);battle.md「敌人意图线索映射目录」是 2026-09-12 成文占位表,
# 现行敌人为 goblin/hobgoblin/bugbear/giant_spider/drider/duergar 系,占位行里的退役
# 敌人 id 只能读作历史留档(真正的现行规格在 敌人系统.md「目标顺位与意图线索」节)。
"伏击": re.compile(r"ambusher|成文占位"),
"heavy_multiplier|重击倍率": re.compile(r"ambusher|heavy_multiplier|成文占位"),
# 拔旗(2026-09-19 退役「旗无移除手段」):06 待办清单「件派生动词白名单」条目说的是
# 「将来件池 ≥30 种后可能开放的第三盘面动作」,是待评估的设计意图而非在用机制。
"拔旗|撤旗": re.compile(r"件派生动词白名单|第三盘面动作|非信息类"),
}
# 行级白名单信号:命中任一即归"留档类"
WHITELIST_RE = re.compile(
@@ -54,8 +123,8 @@ WHITELIST_RE = re.compile(
r"|\bv0\.\d+"
r"|修订记录|版本历史|履行回执|台账"
)
# 节级白名单:版本历史/修订记录节内整段豁免
SECTION_RE = re.compile(r"^#{1,6}\s*(版本历史|修订记录)")
# 节级白名单:版本历史/修订记录/用语规范节内整段豁免(用语规范节的不用列是内容本身)
SECTION_RE = re.compile(r"^#{1,6}\s*(版本历史|修订记录|用语规范)")
COMMENT_RE = re.compile(r"^\s*[;#]")
@@ -70,6 +139,10 @@ def scan(paths):
if SECTION_RE.match(line):
in_version_section = True
hits = [(m.group(0), t) for t in terms for m in [t[0].search(line)] if m]
hits = [
(word, t) for (word, t) in hits
if not (t[0].pattern in TERM_CONTEXT_EXEMPT and TERM_CONTEXT_EXEMPT[t[0].pattern].search(line))
]
# 上一 2 行带留档/作废/历史标记(如"下表留档作废"头注)→ 本行视为留档
prev = " ".join(prev_lines[-2:]) if prev_lines else ""
prev_lines.append(line)
@@ -107,7 +180,7 @@ def main():
paths += sorted(Path(d).rglob("*.yaml"))
paths = [p for p in paths if Path(p).exists()]
findings = scan(paths)
findings = scan(paths) + scan_supplement(paths)
suspect = [f for f in findings if f["klass"] == "疑似现行"]
kept = [f for f in findings if f["klass"] != "疑似现行"]
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""error_scan.py — 脚本错误扫描闸件(判定层通用,供本地闸各段复用)
用法: python tools/error_scan.py <日志文件> [...]
背景: GDScript 运行时脚本错误(SCRIPT ERROR / Parse Error)不改进程退出码、不计失败数,
故闸只能设在外部——本件把 run_smoke.py 的同一套判定抽出来给探针段与套件段共用。
错误标记与 tools/run_smoke.py 的 ERROR_MARKERS 同源(直接 import,禁复制第二份清单)。
退出码:0=无命中;1=命中脚本错误标记;2=用法错或日志不可读。
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from run_smoke import ERROR_MARKERS # noqa: E402 单一事实源,禁在本件另写一份
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
args = sys.argv[1:]
if not args:
print("[error_scan] 用法: python tools/error_scan.py <日志文件> [...]")
return 2
hits = []
for a in args:
p = Path(a)
if not p.is_file():
print("[error_scan] FAIL — 日志不可读: %s" % a)
return 2
for ln in p.read_text(encoding="utf-8", errors="replace").splitlines():
s = ln.strip()
if s and any(k in s for k in ERROR_MARKERS):
hits.append((p.name, s))
if hits:
print("[error_scan] FAIL — 脚本错误标记命中 %d 行(此类错误不改退出码,"
"且会中断所在函数的剩余断言)" % len(hits))
for name, s in hits[:5]:
print(" - [%s] %s" % (name, s[:160]))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+45 -22
View File
@@ -8,12 +8,13 @@ r"""gdd_lint.py — GDD lean 格式门禁
规则(与 .claude/docs/templates/game-design-document.md 纪律一一对应):
R1 散文段落 ≤ 240 字(代码块/表格/列表/引用不计)
R2 表格字符占比 ≥ 60%(代码块不计入分母)
R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"(版本历史节豁免)
R4 必须有「版本历史」节且含表格
R5 流程/状态类章节必须含代码块图(字符画或 Mermaid)
R2 表格字符占比(词条表分域豁免,见 R2_EXEMPT 注)
R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"(版本历史节豁免;
登记处/清单按 RULE_EXEMPT 分域豁免)
R4 必须有「版本历史」节且含表格(登记处按 RULE_EXEMPT 分域豁免)
R5 流程/状态类章节必须含代码块图(字符画或 Mermaid);清单节/词条节按 RULE_EXEMPT 分域豁免
R6 表格列数一致(含转义 \| 识别)
R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号)
R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号;登记处/清单按 RULE_EXEMPT 分域豁免)
R8 核心规则表:规则列必须是"谁·何时·做什么"陈述句所在的可读表——说明/要点列
禁止出现编号引用(AC-x/F-x/EC-x/E-x/OQ-x/Rule-x/义务),引用只允许在依据类列
R9 一页纸节点:任一标题节点的直接内容(到下一标题为止)≤ 80 行——装不下就拆子节点
@@ -50,6 +51,24 @@ R2_EXEMPT = {
"05-不做清单.md", # 清单文:条目散文为体裁
"06-待办清单.md",
"07-体验验证清单.md",
"术语表.md", # 词条表:表格为主体形态,表格占比高是体裁本身
}
# 规则粒度分域豁免(2026-10-15 用户令「清单类文件与术语表不做 GDD 规范性检查」)。
# 原则:**按规则豁免,不按文件拉黑**——体裁规则(R3/R4/R5/R7)对登记处/清单/词条表
# 不适用,但机械可判的完整性规则(R6/R8/R9/R10/R11)一律不豁免,真缺陷不得藏起来。
# 各条理由:
# R3 正文禁修订注记——登记处/清单的条目「带日期+裁决来源」是设计目的本身
# (条目要能回答「这条谁在何时裁的」),禁它等于禁登记处的核心价值。
# R4 必须有版本历史节——06 是「解决即移除」的状态登记,条目不按版本累积,
# 强塞版本历史表反而制造第二套需同步的真相。
# R5 流程/状态类节须含代码块图——节名含「流程/生命周期」即触发,但 07 的
# 「㉖…全流程走查」「㉗…目检」是目检清单节、术语表的「开发流程」是词条节,
# 两者都不是流程描述,属节名误匹配。
# R7 括号跨度 ≤80 字——长括注在登记处/清单里是常态(现状+裁决+归属挤在一行)。
RULE_EXEMPT = {
"06-待办清单.md": {"R3", "R4", "R7"},
"07-体验验证清单.md": {"R3", "R5", "R7"},
"术语表.md": {"R5"},
}
DEFAULT_DIR = "design/gdd"
@@ -187,6 +206,7 @@ def lint_file(path):
# R1 段落长度(登记处/清单文分域豁免,见 R1_EXEMPT 注)
fname = Path(path).name
ex = RULE_EXEMPT.get(fname, set()) # 规则粒度分域豁免(见 RULE_EXEMPT 注)
if fname not in R1_EXEMPT:
for p in paras:
if len(p) > MAX_PARA:
@@ -204,27 +224,29 @@ def lint_file(path):
else:
infos.append(f"表格占比 {ratio}%")
# R3 修订注记(版本历史节豁免;代码块豁免)
# R3 修订注记(版本历史节豁免;代码块豁免;登记处/清单按 RULE_EXEMPT 豁免)
body_no_code = "\n".join(
l for l in text.splitlines() if l not in code_lines and l not in version_lines
)
for m in re.finditer(r"~~.+?~~", body_no_code):
for m in (() if "R3" in ex else re.finditer(r"~~.+?~~", body_no_code)):
issues.append(f"R3 删除线残留: …{m.group(0)[:40]}…")
if re.search(r"作废", body_no_code):
if "R3" not in ex and re.search(r"作废", body_no_code):
line_no = next(
i + 1
for i, l in enumerate(body_no_code.splitlines())
if "作废" in l
)
issues.append(f"R3 \"作废\"注记残留(第 {line_no} 行附近)")
for m in DATE_KEYWORD_RE.finditer(body_no_code):
issues.append(f"R3 正文修订注记: {m.group(0)[:50]}")
if "R3" not in ex:
for m in DATE_KEYWORD_RE.finditer(body_no_code):
issues.append(f"R3 正文修订注记: {m.group(0)[:50]}")
# R4 版本历史
if VERSION_SECTION not in text:
issues.append(f"R4 缺「{VERSION_SECTION}」节")
elif not any(l.strip().startswith("|") for l in version_lines):
issues.append(f"R4 「{VERSION_SECTION}」节无表格")
# R4 版本历史(登记处按 RULE_EXEMPT 豁免——条目「解决即移除」,不按版本累积)
if "R4" not in ex:
if VERSION_SECTION not in text:
issues.append(f"R4 缺「{VERSION_SECTION}」节")
elif not any(l.strip().startswith("|") for l in version_lines):
issues.append(f"R4 「{VERSION_SECTION}」节无表格")
# R10 版本历史版本号唯一性(实证撞号:v0.19 曾被并行两批同时占用)
ver_seen = {} # key: 归一版本号 → [首次原样 token, 出现次数]
@@ -248,20 +270,21 @@ def lint_file(path):
if count > 1:
issues.append(f"R10 版本历史版本号重复: {first}({count} 处)")
# R5 流程章节须含代码块图
cur_section, prev_section = "", ""
# R5 流程章节须含代码块图(清单节/词条节按 RULE_EXEMPT 豁免——节名含「流程」
# 即触发,但目检清单节与术语词条节不是流程描述,属节名误匹配)
cur_section = ""
pending_flow, has_code = False, False
for raw in text.splitlines():
stripped = raw.strip()
if raw.startswith("## "):
if pending_flow and not has_code:
issues.append(f"R5 「{prev_section or cur_section}」节无流程图(须含代码块字符画/Mermaid)")
prev_section, cur_section = cur_section, stripped.lstrip("#").strip()
if pending_flow and not has_code and "R5" not in ex:
issues.append(f"R5 「{cur_section}」节无流程图(须含代码块字符画/Mermaid)")
cur_section = stripped.lstrip("#").strip()
pending_flow = bool(FLOW_SECTION_RE.search(cur_section)) and cur_section != VERSION_SECTION
has_code = False
elif stripped.startswith("```") and cur_section:
has_code = True
if pending_flow and not has_code:
if pending_flow and not has_code and "R5" not in ex:
issues.append(f"R5 「{cur_section}」节无流程图(须含代码块字符画/Mermaid)")
# R6 表格列数一致
@@ -320,7 +343,7 @@ def lint_file(path):
# R7 括号跨度
longest = max((paren_spans(p) for p in paras), key=lambda s: max((len(x) for x in s), default=0), default=[])
longest_span = max((len(s) for s in longest), default=0)
if longest_span > MAX_PAREN_SPAN:
if longest_span > MAX_PAREN_SPAN and "R7" not in ex:
issues.append(f"R7 最长括号跨度 {longest_span} 字(>{MAX_PAREN_SPAN})——疑似审计注记挤进正文")
infos.append(f"最长括号跨度 {longest_span} 字")
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""UI 全局主题批小位图生成脚本(迭代 25 T0,2026-10-13)。
产出(→ game/assets/art/ui/gen/,纯色/几何、像素风):
slider_grabber.png 滑轨柄·常 12×12 CANDLE #F6C667 + PANEL_BORDER #786E58 1px 描边
slider_grabber_highlight.png 滑轨柄·悬 12×12 CANDLE 提亮档(lightened≈12%) + 同描边
slider_grabber_disabled.png 滑轨柄·废 12×12 STONE_DEEP #554D41 + 同描边
spinbox_updown.png SpinBox 箭头 16×16 INK #33261B ▲▼ 合一(Godot 4 SpinBox 走 updown 合并图标槽)
option_arrow.png OptionButton 箭头 8×8 INK 向下三角
checkbox_unchecked.png 勾选框·空 14×14 PANEL_DATA #E9D9B8 + PANEL_BORDER 边
checkbox_checked.png 勾选框·选 14×14 同底边 + INK 对勾
checkbox_unchecked_disabled.png 勾选框·空·废 14×14 同底 + STONE_DEEP 边
checkbox_checked_disabled.png 勾选框·选·废 14×14 同底边 + STONE_DEEP 对勾
色值全部取自 game/presentation/views/ui_kit.gd 既有常量(溯源表见 game/assets/theme/README.md)——
不新增美术素材、不动美术圣经 §7(迭代 25 契约)。滚动条柄不走位图:Godot 4 ScrollBar 的
grabber 是 StyleBox(非 Icon),纯色 StyleBoxFlat 直接承载(见 main_theme.tres)。
♻ 可重跑:本脚本幂等,重复执行覆盖产出;改色值只改本脚本顶部常量后重跑 + 重导。
用法: python tools/gen_theme_icons.py
"""
import os
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "game", "assets", "art", "ui", "gen")
# ── 色值(↔ ui_kit.gd 常量,冻结)────────────────────────────────────────────
INK = (51, 38, 27) # INK #33261B
PANEL_DATA = (233, 217, 184) # PANEL_DATA #E9D9B8
PANEL_BORDER = (120, 110, 88) # PANEL_BORDER #786E58
STONE_DEEP = (85, 77, 65) # STONE_DEEP #554D41
CANDLE = (246, 198, 103) # CANDLE #F6C667
def lighten(c, amount=0.12):
"""复刻 Godot Color.lightened:每通道 + (255-通道)×amount(sRGB 存储值域)"""
return tuple(min(255, round(v + (255 - v) * amount)) for v in c)
def save(img, name):
path = os.path.join(OUT, name)
img.save(path)
print("wrote %s (%dx%d)" % (name, img.width, img.height))
def slider_grabber(fill):
"""12×12 方柄:fill 实心 + PANEL_BORDER 1px 描边"""
img = Image.new("RGBA", (12, 12), (0, 0, 0, 0))
for x in range(12):
for y in range(12):
edge = x in (0, 11) or y in (0, 11)
img.putpixel((x, y), PANEL_BORDER + (255,) if edge else fill + (255,))
return img
def spinbox_updown():
"""16×16 ▲▼ 合一:上三角 y2-7、下三角 y9-14,各宽 12(INK)"""
img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
# 上三角:顶行中心 2px(x7-8),第 6 行满宽 x2-13
for row in range(6):
half = 1 + row # 第 row 行左右各扩 1
for x in range(8 - half, 8 + half):
img.putpixel((x, 2 + row), INK + (255,))
# 下三角:镜像(第 row 行满宽在底)
for row in range(6):
half = 6 - row
for x in range(8 - half, 8 + half):
img.putpixel((x, 9 + row), INK + (255,))
return img
def option_arrow():
"""8×8 向下三角(OptionButton 箭头)"""
img = Image.new("RGBA", (8, 8), (0, 0, 0, 0))
for row in range(5):
half = row + 1
for x in range(4 - half, 4 + half - 1):
img.putpixel((x, 1 + row), INK + (255,))
return img
def checkbox(border, mark):
"""14×14 勾选框:PANEL_DATA 底 + 1px 边;mark 非 None 时画 2px 对勾"""
img = Image.new("RGBA", (14, 14), (0, 0, 0, 0))
for x in range(14):
for y in range(14):
edge = x in (0, 13) or y in (0, 13)
img.putpixel((x, y), border + (255,) if edge else PANEL_DATA + (255,))
if mark is not None:
# 对勾:(3,7)→(6,10)→(11,3),2px 粗(内区 1..12)
seg = [(3, 7), (4, 7), (4, 8), (5, 8), (5, 9), (6, 9),
(6, 10), (7, 10), (7, 11), (8, 11), (8, 10), (9, 10),
(9, 9), (10, 9), (10, 8), (11, 8),
(11, 7), (10, 7), (10, 6), (9, 6), (9, 5), (8, 5),
(8, 4), (7, 4), (7, 3), (6, 3)]
for (x, y) in seg:
if 1 <= x <= 12 and 1 <= y <= 12:
img.putpixel((x, y), mark + (255,))
return img
def main():
os.makedirs(OUT, exist_ok=True)
save(slider_grabber(CANDLE), "slider_grabber.png")
save(slider_grabber(lighten(CANDLE)), "slider_grabber_highlight.png")
save(slider_grabber(STONE_DEEP), "slider_grabber_disabled.png")
save(spinbox_updown(), "spinbox_updown.png")
save(option_arrow(), "option_arrow.png")
save(checkbox(PANEL_BORDER, None), "checkbox_unchecked.png")
save(checkbox(PANEL_BORDER, INK), "checkbox_checked.png")
save(checkbox(STONE_DEEP, None), "checkbox_unchecked_disabled.png")
save(checkbox(STONE_DEEP, STONE_DEEP), "checkbox_checked_disabled.png")
print("done → %s" % OUT)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# golden 基线换代辅助(迭代 19 卡① T2 2026-10-08——呈现类改动批专用;迭代 28 T4 扩菜单全屏基线路径)。
# 权限口径(Q6):呈现类改动批内 agent 自行换代+提交说明**强制写明触发原因**;数值/逻辑批禁碰。
# 前置条件(Q5/S3):素材必须齐盘(本脚本逐项守卫,缺位即拒跑退出 1=基线禁锚回退态;
# 缺位时先拷主区素材+导入边车再跑)。
# 模式:
# --menu(迭代 28 T4 新增):shot_probe 启动态菜单整屏帧 → game/tests/golden/menu_boot.png。
# 全屏基线可行性前置已验证:菜单连续帧差 0.0000%/max_diff=0(2026-10-15 帧差诊断——光柱为
# 程序生成静态贴片、零动画着色器,全屏基线不抖动);采集确定性=探针隔离档(无断点+出厂
# 设置 1280×800),与 render_gate 的 shot_menu 对比段同源同帧。
# 缺省(棋盘,原口径):board_shot_probe 抓 L1-L3 整屏帧+board_meta.json → golden_board_crop.py
# 按 meta 取样坐标裁棋盘区(禁硬编码像素)→ 覆盖 game/tests/golden/ 三张基线。
# 过程截图落 mktemp 临时区即弃(Q2),只有基线产物入库。
# 用法: tools/golden_baseline_refresh.sh [game目录] [--menu] 触发原因写进当批提交说明(强制)。
set -u
G="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
GAME=""
MODE="board"
for a in "$@"; do
case "$a" in
--menu) MODE="menu" ;;
*) if [ -z "$GAME" ]; then GAME="$a"; fi ;;
esac
done
GAME="${GAME:-$REPO/game}"
GAME_WIN="$(cygpath -m "$GAME")"
TMP="$(mktemp -d /tmp/goldenrefresh_XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
if [ "$MODE" = "menu" ]; then
for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \
assets/art/ui/button_stone.png; do
if [ ! -f "$GAME/$a" ]; then
echo "[golden_refresh] 拒跑 — 菜单素材缺位: $a(S3 停机点口径:基线禁锚回退态,先补素材+导入边车)"
exit 1
fi
done
timeout 240 "$G" --path "$GAME_WIN" res://smoke/shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1
st=$?
grep -E "\[shot_probe\]" "$TMP/run.log" | tail -3
if [ "$st" -ne 0 ] || [ ! -s "$TMP/shots/shot_menu.png" ]; then
echo "[golden_refresh] FAIL — shot_probe 退出码=$st(菜单截图缺失/探针自检红,见上)"
exit 1
fi
mkdir -p "$REPO/game/tests/golden"
cp "$TMP/shots/shot_menu.png" "$REPO/game/tests/golden/menu_boot.png" || exit 1
echo "[golden_refresh] 完成 — 菜单全屏基线已代入 game/tests/golden/menu_boot.png(提交说明须写明触发原因)"
exit 0
fi
for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \
assets/art/board/flag_seal.png \
assets/art/character/character_hero_idle.png \
assets/art/board/terrain_wall_n_dungeon.png assets/art/board/terrain_floor_dungeon_1.png \
assets/art/board/terrain_wall_n_underdark.png assets/art/board/terrain_floor_underdark_1.png \
assets/art/board/terrain_wall_n_deep_underdark.png assets/art/board/terrain_floor_deep_underdark_1.png; do
if [ ! -f "$GAME/$a" ]; then
echo "[golden_refresh] 拒跑 — 素材缺位: $a(S3 停机点口径:基线禁锚字形回退态,先补素材+导入边车)"
exit 1
fi
done
timeout 180 "$G" --path "$GAME_WIN" res://smoke/board_shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1
st=$?
grep -E "\[board_shot\]" "$TMP/run.log" | tail -6
if [ "$st" -ne 0 ] || [ ! -s "$TMP/shots/board_meta.json" ]; then
echo "[golden_refresh] FAIL — board_shot_probe 退出码=$st(meta/截图缺失见上)"
exit 1
fi
python tools/golden_board_crop.py "$TMP/shots/board_meta.json" "$TMP/shots" "$TMP/crop" || exit 1
mkdir -p "$REPO/game/tests/golden"
for b in board_L1_dungeon.png board_L2_underdark.png board_L3_deep_underdark.png; do
cp "$TMP/crop/$b" "$REPO/game/tests/golden/$b" || exit 1
done
echo "[golden_refresh] 完成 — 三张基线已换代入 game/tests/golden/(提交说明须写明触发原因)"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
r"""golden_board_crop.py — 棋盘 golden 前置裁剪(迭代 19 卡① T2 2026-10-08)。
board_shot_probe 截整屏视口(get_viewport().get_texture()),golden 逐像素对比前须裁出棋盘区。
本工具按 board_meta.json 取样坐标(board_shot_probe 输出的棋盘视图实测全局矩形)裁剪,
**禁硬编码像素**(迭代 15③ 挂账口径——布局改动只须重跑探针刷新 meta,裁剪代码零改动)。
用法: python tools/golden_board_crop.py <board_meta.json 路径> <截图目录> <输出目录>
输出: <输出目录>/board_<tag>.png(tag=meta 键,如 L1_dungeon)。
容错:矩形越界按图像边界收拢;meta 键缺失/矩形退化(宽高≤0)= 报错退出 1(取证缺失不静默)。
退出码: 0 = 全部裁出 / 1 = 有失败。
"""
import json
import sys
from pathlib import Path
from PIL import Image
def main() -> int:
if len(sys.argv) != 4:
print("用法: python tools/golden_board_crop.py <board_meta.json> <截图目录> <输出目录>")
return 1
meta_p, src_d, dst_d = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])
if not meta_p.exists():
print("[golden_crop] FAIL — board_meta.json 缺失: %s" % meta_p)
return 1
meta = json.loads(meta_p.read_text(encoding="utf-8"))
if not meta:
print("[golden_crop] FAIL — board_meta.json 空表(取样坐标零在案)")
return 1
dst_d.mkdir(parents=True, exist_ok=True)
failures = []
for tag, r in meta.items():
src = src_d / ("board_%s.png" % tag)
if not src.exists():
failures.append("%s: 截图缺失 %s" % (tag, src))
continue
img = Image.open(src).convert("RGB")
try:
x, y, w, h = int(r["x"]), int(r["y"]), int(r["w"]), int(r["h"])
except (KeyError, TypeError, ValueError):
failures.append("%s: meta 矩形字段缺失/非法 %s" % (tag, r))
continue
# 越界收拢到图像边界(全局 rect 可能触视口边)
box = (max(0, x), max(0, y), min(img.size[0], x + w), min(img.size[1], y + h))
if box[2] - box[0] <= 0 or box[3] - box[1] <= 0:
failures.append("%s: 矩形退化 %s(img %s)" % (tag, box, img.size))
continue
img.crop(box).save(dst_d / ("board_%s.png" % tag))
print("[golden_crop] %s — %s -> %s(%dx%d)" % (tag, box, dst_d / ("board_%s.png" % tag),
box[2] - box[0], box[3] - box[1]))
if failures:
print("[golden_crop] FAIL——")
for f in failures:
print(" - %s" % f)
return 1
print("[golden_crop] PASS(%d 张棋盘区裁剪全出)" % len(meta))
return 0
if __name__ == "__main__":
sys.exit(main())
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""parity_anchor.py — 迭代27重构「逐位一致对拍」锚工具(需求卡片-代码架构技术债修复批 §7 V1 核心验收)
载体 = adventure_monte_carlo.gd 同种子批量完整局(SceneTree headless,自持 ConfigDB,零渲染)。
三变体钉死(种子面错开,覆盖 base / 同调账 / 商店-GP 账三类 T2/T3 重构面):
base : -- 20 71000 both min off off off
attune : -- 20 72000 both min on off off
shop : -- 20 73000 both min off on off
锚双面:
A. RUNSUM 逐行块(stdout 局级摘要面,字符串列表原样入库 anchor.json——红灯时可打印首条差异行);
B. playtest JSONL 全事件流(剔除带墙钟的 session/diagnosis 两类行;文件名本身带时间戳不入比对面)。
比对口径 = 字节级逐位一致(同引擎二进制+同种子确定性,冻结前已实测两跑全同)。
原始面不入库(体量 MB 级):红灯取证用 dump 模式对重构前后各落一份原始数据再 diff——
旧提交随时可重生成同字节原始面(确定性保证),锚只存 sha256 清单。
用法:
python tools/parity_anchor.py freeze # 冻结锚(重构前基线,写入 game/tests/evidence/parity/anchor.json)
python tools/parity_anchor.py check # 对拍:重跑同参批,与锚逐位比对(全同退出 0,差异退出 1 并打印首差异)
python tools/parity_anchor.py dump --out DIR # 只跑批落原始面到 DIR(红灯取证)
可选 --variants base,attune,shop / --quick(只跑 base)
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe"
GAME_DIR = ROOT / "game"
ANCHOR_PATH = ROOT / "game" / "tests" / "evidence" / "parity" / "anchor.json"
USER_LOGS = Path(os.environ.get("APPDATA", "")) / "Godot" / "app_userdata" / "DungeonSweeperVerticalSlice" / "logs"
# 变体表:名字 → (seed0, attune, shop)。(N=20/策略 both/route min/feat off 全变体钉死。)
VARIANTS = {
"base": (71000, "off", "off"),
"attune": (72000, "on", "off"),
"shop": (73000, "off", "on"),
}
N_RUNS = 20
BATCH_RE = re.compile(r"^BATCH (\w+) N=(\d+) 终局计数=(.*) 日志=user://logs/(playtest_\d+_\S+\.jsonl)\s*$")
TS_ROW_KINDS = ("session", "diagnosis") # 带墙钟 ts 的两类行——剔出比对面
def run_variant(name: str, seed0: int, attune: str, shop: str, timeout: int = 560):
"""跑一个变体批,返回 (runsum_lines, {policy: jsonl绝对路径})。"""
before = set(p.name for p in USER_LOGS.glob("playtest_*.jsonl")) if USER_LOGS.is_dir() else set()
cmd = [GODOT, "--headless", "--path", str(GAME_DIR), "--script",
"res://smoke/adventure_monte_carlo.gd", "--",
str(N_RUNS), str(seed0), "both", "min", attune, shop, "off"]
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=timeout)
out = (proc.stdout or "") + (proc.stderr or "")
runsum = [ln for ln in out.splitlines() if ln.startswith("RUNSUM ")]
logs = {}
for ln in out.splitlines():
m = BATCH_RE.match(ln)
if m:
logs[m.group(1)] = m.group(4)
after = set(p.name for p in USER_LOGS.glob("playtest_*.jsonl")) if USER_LOGS.is_dir() else set()
fresh = after - before
resolved = {}
for policy, fname in logs.items():
p = USER_LOGS / fname
if p.is_file():
resolved[policy] = p
else:
cand = [f for f in fresh if f.startswith("playtest_%d_" % seed0)]
if len(cand) == 1:
resolved[policy] = USER_LOGS / cand[0]
else:
print("[parity] FAIL:变体 %s 的 %s 日志文件定位失败(fresh=%s)" % (name, policy, cand))
sys.exit(2)
expected_policies = 2
if len(runsum) != N_RUNS * expected_policies or len(resolved) != expected_policies:
print("[parity] FAIL:变体 %s 产出不完整(RUNSUM %d 行 / 日志 %d 份)" % (name, len(runsum), len(resolved)))
print(out[-3000:])
sys.exit(2)
return runsum, resolved
def filter_jsonl(path: Path) -> bytes:
"""剔除带 ts 行,保留原始字节行序(bit-exact;不做任何再序列化)。"""
kept = []
with open(path, "rb") as f:
for raw in f.read().split(b"\n"):
if not raw.strip():
continue
head = raw[:48]
if any(('{"kind":"%s"' % k).encode() in head for k in TS_ROW_KINDS):
continue
kept.append(raw)
return b"\n".join(kept) + b"\n"
def collect(variants, quick=False):
if quick:
variants = [v for v in variants if v == "base"] or ["base"]
data = {}
for v in variants:
seed0, attune, shop = VARIANTS[v]
t0 = time.time()
print("[parity] 跑批 %s(N=%d×2 策略,seed0=%d,attune=%s,shop=%s)…" % (v, N_RUNS, seed0, attune, shop))
runsum, logs = run_variant(v, seed0, attune, shop)
faces = {"runsum": runsum, "jsonl": {}}
for policy in sorted(logs):
body = filter_jsonl(logs[policy])
faces["jsonl"][policy] = {
"sha256": hashlib.sha256(body).hexdigest(),
"rows": body.count(b"\n"),
}
data[v] = faces
print("[parity] %s 完成(%.0fs,RUNSUM %d 行,JSONL %s)" %
(v, time.time() - t0, len(runsum),
", ".join("%s=%s" % (p, faces["jsonl"][p]["sha256"][:12]) for p in sorted(faces["jsonl"]))))
return data
def dump_variant_raw(variants, outdir: Path):
outdir.mkdir(parents=True, exist_ok=True)
for v in variants:
seed0, attune, shop = VARIANTS[v]
runsum, logs = run_variant(v, seed0, attune, shop)
(outdir / ("%s_runsum.txt" % v)).write_text("\n".join(runsum) + "\n", encoding="utf-8", newline="\n")
for policy, p in logs.items():
(outdir / ("%s_%s.jsonl" % (v, policy))).write_bytes(filter_jsonl(p))
print("[parity] dump %s → %s" % (v, outdir))
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(description="迭代27重构逐位一致对拍锚")
ap.add_argument("mode", choices=["freeze", "check", "dump"])
ap.add_argument("--variants", default="base,attune,shop")
ap.add_argument("--quick", action="store_true", help="只跑 base 变体")
ap.add_argument("--out", default="", help="dump 模式输出目录")
args = ap.parse_args()
variants = [v.strip() for v in args.variants.split(",") if v.strip() in VARIANTS]
if not variants:
print("[parity] FAIL:无有效变体(可选 %s)" % list(VARIANTS))
return 2
if args.mode == "dump":
dump_variant_raw(variants, Path(args.out if args.out else str(ROOT / "tools" / "parity_dump")))
return 0
data = collect(variants, quick=args.quick)
if args.mode == "freeze":
ANCHOR_PATH.parent.mkdir(parents=True, exist_ok=True)
anchor = {"variants": {v: data[v] for v in variants}}
ANCHOR_PATH.write_text(json.dumps(anchor, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n")
print("[parity] 锚已冻结 → %s(变体 %s)" % (ANCHOR_PATH, variants))
return 0
# check
if not ANCHOR_PATH.is_file():
print("[parity] FAIL:锚不存在(先 freeze)→ %s" % ANCHOR_PATH)
return 2
anchor = json.loads(ANCHOR_PATH.read_text(encoding="utf-8"))
bad = 0
for v in variants:
if v not in anchor["variants"]:
print("[parity] FAIL:锚无变体 %s(锚含 %s)" % (v, list(anchor["variants"])))
bad += 1
continue
old = anchor["variants"][v]
new = data[v]
if old["runsum"] != new["runsum"]:
bad += 1
print("[parity] ✗ %s RUNSUM 漂移(%d 行 vs %d 行)" % (v, len(old["runsum"]), len(new["runsum"])))
for i, (a, b) in enumerate(zip(old["runsum"], new["runsum"])):
if a != b:
print("[parity] 首差异第 %d 行:" % i)
print("[parity] 锚: %s" % a[:600])
print("[parity] 今: %s" % b[:600])
break
for policy in sorted(old["jsonl"]):
o, n = old["jsonl"][policy], new["jsonl"].get(policy)
if n is None:
bad += 1
print("[parity] ✗ %s/%s 本次缺日志" % (v, policy))
elif o["sha256"] != n["sha256"]:
bad += 1
print("[parity] ✗ %s/%s JSONL 逐位漂移(锚 %s… 行数 %d vs 今 %s… 行数 %d)" %
(v, policy, o["sha256"][:12], o["rows"], n["sha256"][:12], n["rows"]))
if bad:
print("[parity] 逐位一致对拍 ✗ 失败 %d 项——该波回炉(V1 红线)" % bad)
return 1
print("[parity] 逐位一致对拍 ✓ 全同(变体 %s,RUNSUM+JSONL 双面逐位一致)" % variants)
return 0
if __name__ == "__main__":
sys.exit(main())
+91 -24
View File
@@ -5,17 +5,27 @@
# 段:ui_input_probe=默认必跑;ui_struct_probe=默认必跑(ui-struct-probe mini-loop 2026-10-05 注册——
# 全 UI 屏结构断言:按钮互压/等宽/出屏/零尺寸/标题屏双列);ui_contrast_probe=默认必跑(UI 设计对齐批
# T2 2026-10-06 注册——UI 文字对比度机检 ≥4.5:1 无区域例外,测点表随批维护);--click=加跑 ui_click_probe 行为走查
# (自退出+退出码闸;超时多半=探针未适配新停点层,归 probe适配批);shot_probe 禁入本闸——
# 必须窗口化运行(headless 无渲染面,探针内已有 fail-fast 护栏),归真人目检。
# 用法: tools/probe_gate.sh [game目录] [--click] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree)
# (自退出+退出码闸;超时多半=探针未适配新停点层,归 probe适配批)。
# layout_probe=默认必跑(2026-10-15 建——分辨率几何断言,档位表是数据/断言是一份函数,
# 增删档位只改表内一行;用 content_scale_size 改逻辑视口,headless 下真生效——
# 旧 ui_layout_probe 用 get_window().size 在 headless 下不改变视口,其「1920 档恒定」
# 断言是永远为真的假断言,随该探针退役)。
# 判定口径(迭代 29 T1 三硬闸):每段走 verdict 函数——①退出码为唯一权威判据(PASS 串降级为日志)
# ②脚本错误扫描命中即红(tools/error_scan.py,标记与 run_smoke.py 同源)③PASS 串保留为附加判据。
# --full 段(迭代 19 卡① T3 收编 2026-10-08——重闸件):ui_probe/ui_tune_probe/t17_defer_probe/fx_probe
# 四件带断言探针收进(此前游离在一切闸外只能手敲);轻闸默认段断言面零删减零改动(--full 只增不改)。
# 渲染面归 render_gate.sh 正主(窗口化:shot_probe 七屏+fx_shot/board_shot 取证+棋盘 golden 逐像素)——
# 旧头注「shot_probe 禁入本闸……归真人目检」纪律句随三闸口径扩容改写退役(2026-10-07 用户授权=历史偷懒标记)。
# 用法: tools/probe_gate.sh [game目录] [--click] [--full] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree)
# 依赖: Git Bash(coreutils timeout/taskkill) + PowerShell;Godot 路径可用环境变量 GODOT_BIN 覆盖。
set -u
G="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
GAME=""; WITH_CLICK=0
GAME=""; WITH_CLICK=0; WITH_FULL=0
for a in "$@"; do
case "$a" in
--click) WITH_CLICK=1 ;;
--full) WITH_FULL=1 ;;
*) GAME="$a" ;;
esac
done
@@ -25,6 +35,39 @@ TMP="$(mktemp -d /tmp/probegate_XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
_rc=0
# 错误扫描闸件用纯标准库 python(不依赖 PIL),优先带 PIL 的 gamedev venv 以保持与重闸同解释器。
PY=""
for cand in "/d/dev/python/envs/gamedev/Scripts/python.exe" "python" "python3"; do
if "$cand" -c "import sys" >/dev/null 2>&1; then PY="$cand"; break; fi
done
if [ -z "$PY" ]; then
echo "[probe_gate] 警告:未找到 python,脚本错误扫描闸不生效(三硬闸缺一)"
fi
# 判定三硬闸——各探针段统一走本函数,禁再手拼判定:
# ①退出码为唯一权威判据(PASS 串降级为日志,不得再当判据)
# ②脚本错误扫描命中即红(SCRIPT ERROR/Parse Error 不改退出码,只能外部扫)
# ③签名标记保留为附加判据(退出码 0 却无该探针的签名行=静默退出,仍判红)。
# 标记默认 PASS;ui_click_probe 本就走退出码语义、日志不写 PASS,传它自己的摘要标记。
verdict() { # verdict <探针名> <日志路径> <退出码> [签名标记]
local name="$1" log="$2" st="$3" mark="${4:-PASS}"
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[probe_gate] FAIL — $name 超时挂死(exit=$st),孤儿已清"; return 1
fi
if [ "$st" -ne 0 ]; then
echo "[probe_gate] FAIL — $name 退出码=$st(退出码为唯一权威判据,PASS 串不抵)"; return 1
fi
if [ -n "$PY" ] && ! "$PY" "$REPO/tools/error_scan.py" "$log" >"$TMP/err.txt" 2>&1; then
echo "[probe_gate] FAIL — $name 脚本错误扫描命中:"
sed -n '1,6p' "$TMP/err.txt" | sed 's/^/ /'
return 1
fi
if ! grep -q "$mark" "$log"; then
echo "[probe_gate] FAIL — $name 未见签名标记「$mark」(exit=$st——退出码 0 但零输出亦判红)"; return 1
fi
return 0
}
orphans() { # 列出命令行含本 game 路径的 Godot 进程 PID(console wrapper+真引擎都算)
printf "Get-CimInstance Win32_Process -Filter \"Name like 'Godot%%'\" | Where-Object { \$_.CommandLine -like '*%s*' } | ForEach-Object { \$_.ProcessId }\n" "$GAME_WIN" > "$TMP/k.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -w "$TMP/k.ps1")" 2>/dev/null | tr -d '\r'
@@ -47,42 +90,66 @@ echo "======== ui_input_probe ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_input_probe.tscn >"$TMP/uip.log" 2>&1
st=$?; kill_orphans
grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[probe_gate] FAIL — ui_input_probe 超时挂死(exit=$st),孤儿已清"; _rc=1
elif ! grep -q "PASS" "$TMP/uip.log"; then
echo "[probe_gate] FAIL — ui_input_probe 未见 PASS(exit=$st)"; _rc=1
fi
verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1
echo "======== ui_struct_probe ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_struct_probe.tscn >"$TMP/stp.log" 2>&1
st=$?; kill_orphans
grep -E "PASS|FAIL" "$TMP/stp.log" | head -12
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[probe_gate] FAIL — ui_struct_probe 超时挂死(exit=$st),孤儿已清"; _rc=1
elif ! grep -q "PASS" "$TMP/stp.log"; then
echo "[probe_gate] FAIL — ui_struct_probe 未见 PASS(exit=$st)"; _rc=1
fi
verdict "ui_struct_probe" "$TMP/stp.log" "$st" || _rc=1
echo "======== layout_probe(分辨率几何·两档) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/layout_probe.tscn >"$TMP/lay.log" 2>&1
st=$?; kill_orphans
grep -E "PASS|FAIL" "$TMP/lay.log" | head -12
verdict "layout_probe" "$TMP/lay.log" "$st" || _rc=1
echo "======== ui_contrast_probe ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_contrast_probe.tscn >"$TMP/ctp.log" 2>&1
st=$?; kill_orphans
grep -E "PASS|FAIL" "$TMP/ctp.log" | head -12
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[probe_gate] FAIL — ui_contrast_probe 超时挂死(exit=$st),孤儿已清"; _rc=1
elif ! grep -q "PASS" "$TMP/ctp.log"; then
echo "[probe_gate] FAIL — ui_contrast_probe 未见 PASS(exit=$st)"; _rc=1
fi
verdict "ui_contrast_probe" "$TMP/ctp.log" "$st" || _rc=1
echo "======== screen_flow_probe(L3 屏壳切换·迭代 29 T4) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/screen_flow_probe.tscn >"$TMP/sfp.log" 2>&1
st=$?; kill_orphans
grep -E "screen_flow_probe" "$TMP/sfp.log" | tail -3
verdict "screen_flow_probe" "$TMP/sfp.log" "$st" "screen_flow_probe" || _rc=1
if [ "$WITH_CLICK" -eq 1 ]; then
echo "======== ui_click_probe 行为走查 ========"
timeout 240 "$G" --headless --path "$GAME_WIN" res://smoke/ui_click_probe.tscn >"$TMP/clk.log" 2>&1
st=$?; kill_orphans
grep -E "click_probe" "$TMP/clk.log" | tail -4
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[probe_gate] FAIL — ui_click_probe 超时(exit=$st):挂死或探针未适配新停点层(归 probe适配批),孤儿已清"; _rc=1
elif [ "$st" -ne 0 ]; then
echo "[probe_gate] FAIL — ui_click_probe 退出码=$st(done=false 或 问题>0)"; _rc=1
fi
verdict "ui_click_probe" "$TMP/clk.log" "$st" "click_probe" || _rc=1
fi
# ── --full 收编段(迭代 19 卡① T3 2026-10-08——四件带断言探针入闸;实测时长 ui_probe 17.4s/
# ui_tune_probe 49.6s/t17_defer_probe 63.0s/fx_probe 21.9s,timeout 120=2-7×余量) ──
if [ "$WITH_FULL" -eq 1 ]; then
echo "======== ui_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_probe.tscn >"$TMP/flp.log" 2>&1
st=$?; kill_orphans
grep -E "\[ui_probe\]" "$TMP/flp.log" | tail -2
verdict "ui_probe" "$TMP/flp.log" "$st" || _rc=1
echo "======== ui_tune_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_tune_probe.tscn >"$TMP/flt.log" 2>&1
st=$?; kill_orphans
grep -E "\[tune_probe\]" "$TMP/flt.log" | tail -2
verdict "ui_tune_probe" "$TMP/flt.log" "$st" || _rc=1
echo "======== t17_defer_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/t17_defer_probe.tscn >"$TMP/fld.log" 2>&1
st=$?; kill_orphans
grep -E "\[t17_defer\]" "$TMP/fld.log" | tail -2
verdict "t17_defer_probe" "$TMP/fld.log" "$st" || _rc=1
echo "======== fx_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/fx_probe.tscn >"$TMP/flx.log" 2>&1
st=$?; kill_orphans
grep -E "\[fx_probe\]" "$TMP/flx.log" | tail -2
verdict "fx_probe" "$TMP/flx.log" "$st" || _rc=1
fi
echo "======== 残留检查(批末闸) ========"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# 渲染闸正主(迭代 19 卡① 自动化验收补齐批 2026-10-08 落码——T1/T2)——窗口化自动闸。
# 迭代 15③ 挂账「渲染五闸」机器补跑半随本闸落籍(shot_probe/golden_compare/board_shot 三件入闸;
# fx_probe 逻辑层归 probe_gate --full——归宿对账见卡①任务书 §一;
# ui_layout_probe 原列此处,2026-10-15 用户裁「无使用的探针删除」随批退役,1920 档布局机检面待补)。
# 骨架复用 probe_gate:①每段 timeout 守卫(挂死=FAIL 不静默)②段前预清+段后清本 game 目录孤儿
# Godot(按命令行精确匹配本 game 路径,不误杀其他 worktree 会话)③末尾残留清点=批末闸(必须 0)。
# 段:
# ① shot_probe 七屏窗口化截图(menu/settings/career/camp/level/battle/status——迭代 19 卡① T1 扩屏)
# → tools/shot_checker.py A-D 语义断言自动判定(A 屏级骨架全屏/B 文字对比度/C 明度三档带/
# D 文字禁压纹理;E 焦点预算=语义面仍归人闸不机检)。首跑存量 FAIL 按 S1 停机点全量呈报登记 06。
# ② 取证段(Q7:超时/零字节/文件缺失任一即 FAIL——「渲染真实发生」必须成立,不做像素判定):
# fx_shot_probe 三时刻抓帧(行到达/结算排播/轮末连帧)+ board_shot_probe L1-L3 棋盘抓帧。
# ③ 棋盘 golden 逐像素(T2):board 整屏帧按 board_meta.json 取样坐标裁剪棋盘区
# (tools/golden_board_crop.py——禁硬编码像素)→ tools/golden_compare.py 对
# game/tests/golden/ 三张基线(容差 provisional:差 ≤16/changed ≤2%,S2 停机点口径)。
# 素材缺位=判红(2026-10-17 改;此前是 SKIP 不判红——但缺盘时这一段根本不对比,闸末仍打
# 「全绿」exit 0,等于空跑判绿。基线生成期素材缺位=L3 停机,运行期同样无权出验收结论)。
# ④ 基准档 1280 单档(Q4)。1920 档注记:16:9 全屏下 stretch=canvas_items/aspect=keep 等比
# 1.35×+左右黑边各 96px,构图与基准档一致(迭代 9 分辨率案甲)——分辨率维度不重复建闸。
# (原由 ui_layout_probe 两档断言承担,探针已随 2026-10-15 裁「无使用的探针删除」退役,
# 1920 布局机检面现无承载,待补。)
# 过程产物纪律(Q2):一切截图 PNG 落 mktemp 临时区,闸毕即弃——任何过程 PNG 禁入 git;
# 例外=game/tests/golden/ 基线(测试资产入库,.gitignore 已核不挡)。
# 窗口化运行会短暂弹 Godot 窗口(本机可开窗口径,2026-10-07 用户令);禁改 --headless 跑本闸
# (headless 无渲染面,探针内 fail-fast 护栏会红——那是护栏生效不是闸红)。
# 用法: tools/render_gate.sh [game目录] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree)
# 依赖: Git Bash(coreutils timeout/taskkill) + PowerShell + python(PIL);Godot 路径可用环境变量 GODOT_BIN 覆盖。
set -u
G="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
GAME="${1:-$REPO/game}"
GAME_WIN="$(cygpath -m "$GAME")" # Godot --path 需 Windows 形式
TMP="$(mktemp -d /tmp/rendergate_XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
_rc=0
orphans() { # 列出命令行含本 game 路径的 Godot 进程 PID(console wrapper+真引擎都算)
printf "Get-CimInstance Win32_Process -Filter \"Name like 'Godot%%'\" | Where-Object { \$_.CommandLine -like '*%s*' } | ForEach-Object { \$_.ProcessId }\n" "$GAME_WIN" > "$TMP/k.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -w "$TMP/k.ps1")" 2>/dev/null | tr -d '\r'
}
kill_orphans() {
local p; for p in $(orphans); do taskkill //F //PID "$p" >/dev/null 2>&1; done
}
# 冷检出刷导入(.godot 不入 git,fresh worktree 首跑一次性付)
if [ ! -f "$GAME/.godot/uid_cache.bin" ]; then
echo "[render_gate] 冷检出:editor --quit 刷导入(一次性,可到 1-2 分钟)"
timeout 300 "$G" --headless --path "$GAME_WIN" --editor --quit >/dev/null 2>&1
kill_orphans
fi
pre="$(orphans | wc -l | tr -d ' ')"
if [ "$pre" -gt 0 ]; then echo "[render_gate] 预清本闸残留 Godot×$pre"; kill_orphans; fi
echo "======== shot_probe 七屏截图 → shot_checker A-D ========"
SHOT="$TMP/shots"
timeout 180 "$G" --path "$GAME_WIN" res://smoke/shot_probe.tscn -- "$SHOT" >"$TMP/shot.log" 2>&1
st=$?; kill_orphans
grep -E "\[shot_probe\]" "$TMP/shot.log" | tail -12
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[render_gate] FAIL — shot_probe 超时挂死(exit=$st),孤儿已清"; _rc=1
elif [ "$st" -ne 0 ] || ! grep -q "问题=0" "$TMP/shot.log"; then
echo "[render_gate] FAIL — shot_probe 未干净完成(exit=$st,缺失屏/空文件/路径断裂见上)"; _rc=1
fi
if [ "$_rc" -eq 0 ]; then
python tools/shot_checker.py "$SHOT" shot_menu.png shot_settings.png shot_career.png shot_camp.png shot_level.png shot_battle.png shot_status.png | sed 's/^/[render_gate] /'
_rc="${PIPESTATUS[0]}"
[ "$_rc" -ne 0 ] && echo "[render_gate] FAIL — shot_checker A-D 断言存在红(S1 停机点:首跑存量 FAIL 全量呈报登记 06)"
fi
echo "======== fx_shot_probe 三时刻取证(Q7) ========"
FX="$TMP/fxshots"
timeout 180 "$G" --path "$GAME_WIN" res://smoke/fx_shot_probe.tscn -- "$FX" >"$TMP/fx.log" 2>&1
st=$?; kill_orphans
grep -E "\[fx_shot_probe\]" "$TMP/fx.log" | tail -4
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[render_gate] FAIL — fx_shot_probe 超时挂死(exit=$st),孤儿已清"; _rc=1
elif [ "$st" -ne 0 ]; then
echo "[render_gate] FAIL — fx_shot_probe 退出码=$st(取证失败=闸红)"; _rc=1
fi
fx_evidence() { # 取证双检:模式文件存在且非零字节(零字节/缺失任一即 FAIL——Q7)
local pat="$1" min="$2" n=0 f
for f in "$FX"/$pat; do
[ -f "$f" ] || continue
[ -s "$f" ] || { echo "[render_gate] FAIL — 取证零字节: $f"; _rc=1; return; }
n=$((n+1))
done
if [ "$n" -lt "$min" ]; then echo "[render_gate] FAIL — 取证文件缺失: $pat 仅 $n/<$min"; _rc=1; fi
}
fx_evidence "fx_dig*.png" 1
fx_evidence "fx_settle*.png" 1
fx_evidence "fx_round*.png" 6
fx_evidence "fx_final.png" 1
echo "======== board_shot_probe L1-L3 取证(Q7) ========"
BD="$TMP/boardshots"
timeout 180 "$G" --path "$GAME_WIN" res://smoke/board_shot_probe.tscn -- "$BD" >"$TMP/bd.log" 2>&1
st=$?; kill_orphans
grep -E "\[board_shot\]" "$TMP/bd.log" | tail -6
if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then
echo "[render_gate] FAIL — board_shot_probe 超时挂死(exit=$st),孤儿已清"; _rc=1
elif [ "$st" -ne 0 ]; then
echo "[render_gate] FAIL — board_shot_probe 退出码=$st(取证失败=闸红)"; _rc=1
fi
for b in board_L1_dungeon.png board_L2_underdark.png board_L3_deep_underdark.png board_meta.json; do
if [ ! -s "$BD/$b" ]; then echo "[render_gate] FAIL — 取证缺失/零字节: $b"; _rc=1; fi
done
echo "======== 棋盘 golden 逐像素(T2——裁剪→对比,容差 provisional) ========"
if [ ! -d "$REPO/game/tests/golden" ]; then
echo "[render_gate] FAIL — game/tests/golden/ 基线目录缺失(基线在库=验收断言②)"; _rc=1
elif [ ! -s "$BD/board_meta.json" ]; then
echo "[render_gate] SKIP — board_meta.json 缺失(取证段已红),golden 对比不判"
else
# 素材缺位=判红(2026-10-17 由 SKIP 改):缺盘态下这一段一个像素都不比,而闸末照样打印
# 「全绿」退出码 0——那是空跑判绿,比误红危险得多(2026-10-16 菜单废基线事故正是靠它蒙过
# worktree 自检的)。基线锚在素材齐盘态(Q5),缺盘态本就无权出验收结论。
_asset_miss=0
for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \
assets/art/board/flag_seal.png \
assets/art/character/character_hero_idle.png \
assets/art/board/terrain_wall_n_dungeon.png assets/art/board/terrain_floor_dungeon_1.png \
assets/art/board/terrain_wall_n_underdark.png assets/art/board/terrain_floor_underdark_1.png \
assets/art/board/terrain_wall_n_deep_underdark.png assets/art/board/terrain_floor_deep_underdark_1.png; do
[ -f "$GAME/$a" ] || { echo "[render_gate] FAIL — 棋盘素材缺位: $a(Q5:基线锚素材齐盘态,缺盘不得出验收结论)"; _asset_miss=1; }
done
if [ "$_asset_miss" -eq 0 ]; then
CROP="$TMP/crop"
python tools/golden_board_crop.py "$BD/board_meta.json" "$BD" "$CROP" | sed 's/^/[render_gate] /'
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
echo "[render_gate] FAIL — 棋盘区裁剪失败"; _rc=1
else
python tools/golden_compare.py "$REPO/game/tests/golden" "$CROP" \
board_L1_dungeon.png board_L2_underdark.png board_L3_deep_underdark.png | sed 's/^/[render_gate] /'
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
echo "[render_gate] FAIL — golden 逐像素超容差(S2 停机点:字体/平台误红呈报后定容差)"; _rc=1
fi
fi
fi
fi
echo "======== 菜单 golden 逐像素(迭代 28 T4——启动态全屏基线)========"
if [ ! -s "$REPO/game/tests/golden/menu_boot.png" ]; then
echo "[render_gate] FAIL — game/tests/golden/menu_boot.png 基线缺失(菜单 golden 未建——tools/golden_baseline_refresh.sh --menu)"; _rc=1
elif [ ! -s "$SHOT/shot_menu.png" ]; then
echo "[render_gate] SKIP — shot_menu.png 缺失(shot_probe 段已红),菜单 golden 不判"
elif [ ! -f "$GAME/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf" ] || [ ! -f "$GAME/assets/art/ui/button_stone.png" ]; then
echo "[render_gate] FAIL — 菜单素材缺位(字体/按钮石纹):基线锚素材齐盘态,缺盘不得出验收结论(2026-10-17 由 SKIP 改判红)"; _rc=1
else
MENUG="$TMP/menugolden"
mkdir -p "$MENUG"
cp "$SHOT/shot_menu.png" "$MENUG/menu_boot.png"
python tools/golden_compare.py "$REPO/game/tests/golden" "$MENUG" menu_boot.png | sed 's/^/[render_gate] /'
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
echo "[render_gate] FAIL — 菜单 golden 超容差(S2 停机点口径:核对热区——按钮换墨/布局变更属预期面=显式重制基线+呈报;超预期面=L3)"; _rc=1
fi
fi
echo "======== 残留检查(批末闸) ========"
left="$(orphans | wc -l | tr -d ' ')"
total="$(tasklist 2>/dev/null | grep -ci '^Godot' || true)"
echo "本闸残留 Godot=$left(必须0) 全局 Godot 进程=$total(其他会话的仅报数不算账)"
if [ "$left" -ne 0 ]; then echo "[render_gate] FAIL — 本闸残留未清零"; kill_orphans; _rc=1; fi
if [ "$_rc" -eq 0 ]; then echo "[render_gate] 全绿"; else echo "[render_gate] 存在 FAIL,见上"; fi
exit "$_rc"
+6 -2
View File
@@ -21,15 +21,19 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# 1) 顶层目录白名单(点开头目录一律忽略)
TOP_DIRS = {"archive", "artwork", "design", "game", "sim-data", "tools"}
# reports/ 收编(2026-10-17 用户裁「迭代批禁写 design/」):迭代实现报告/代拍板追认单/GDD 反哺点
# 清单的家——实现批只准往这里写,design/ 全目录对它只读。只放 .md,不放图(图像白名单不含此目录)。
TOP_DIRS = {"archive", "artwork", "design", "game", "sim-data", "tools", "reports"}
# 2) 根目录文件白名单
ROOT_FILES = {"CLAUDE.md", "active.md", "roadmap.md", "stage.txt", "review-mode.txt",
".gitignore", ".zcodeignore"}
# 3) 图像允许目录(相对路径前缀;archive/ 为归档 zip 不扫)
# design/qa/ 收编(迭代 5 返工批 2026-09-29——U2 目检预览图/QA 呈现产物归位,commit 留痕)
# game/tests/golden/ 收编(迭代 19 卡① T2/T6 2026-10-08——棋盘 golden 基线=机检测试资产非美术素材,
# 素材齐盘态生成入库【基线换代权限 Q6】;T1 落库时漏改本白名单致熵增闸红,同批修订 commit 留痕)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".pxo", ".psd", ".bmp"}
IMAGE_PREFIXES = ("artwork/", "game/assets/", "design/art/references/", "design/dev/architecture/",
"design/qa/")
"design/qa/", "game/tests/golden/")
# 4) 退役路径(目录或文件任一再现即 FAIL)
RETIRED = ["production", "docs", "tests", "qa-data",
"design/art/candidates", "production/mockups",
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env bash
# 重闸唯一入口(2026-10-15 用户令「迭代完成前必须全量跑一遍所有测试」)——
# 闸清单的唯一定义处。文档只写「收口跑本脚本」,禁在文档里另列闸件清单(那正是 iter27
# 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。
#
# 跑完写回执 game/tests/.gates/receipt.json(commit + 各闸退出码 + 存量基线比对 + 时间戳);
# .zcode/hooks/worktree-guard.sh 在主工作区 git merge 时校验本回执,缺或红即拦截。
#
# 用法:tools/run_all_gates.sh [--code|--docs|--quick]
# --code 只跑代码域闸(01 冒烟/02 单元/03 探针 --click/04 探针 --full/05 渲染闸/09 静态扫描
# /11 回放确定性对拍+10 熵增闸)——本批改动只碰 game/、tools/、artwork/ 时用本档。
# --docs 只跑文档域闸(06 gdd_lint/07 doc_link/08 doc_term/08b active.md 体量+10 熵增闸)
# ——本批改动只碰 design/、active.md、roadmap.md、CLAUDE.md 时用本档。
# --quick 全量档的中段自检形态:跳过渲染闸(窗口化,需开显示器)与静态扫描;完成出库禁用。
# 不带参数 = 全量档(两域全跑)。改闸件本身、或改动同时跨两域时,按 process-conventions §6.2
# 共享件例外跑全量。
# **域划分只在本脚本里定义一处**——文档只写「按改动域跑本脚本」,禁另列闸清单(清单散落多处
# 正是 iter27 漏跑 probe_gate --full 的成因)。
#
# 判定口径:每道闸与 game/tests/.gates/baseline.json 里的存量基线比对——
# 基线绿 + 现绿 → 绿
# 基线红 + 现红且清单未变 → 「存量红·对基线零新增」,不判失败(但会在回执里留名)
# 基线红 + 现红但清单新增 → 红(新增红项)
# 任意闸 exit 与基线不符 → 红
# 本次未跑的闸(--quick 跳过/条件闸件未触面)→ 不比闸况,只记进回执 not_run
# 基线文件首次自动生成(把当次结果当基线),此后每次跑批与之比。
# 分域跑批只比本域跑过的闸——域外闸不入 NAMES,不产生「基线绿 → 现红」的假变化。
#
# 退出码:0=全绿或仅存量红零新增;1=有新增红/闸况变化;2=用法错
set -u
REPO="$(cd "$(dirname "$0")/.." && pwd)"
GAME="$REPO/game"
STAMP_DIR="$GAME/tests/.gates"
RECEIPT="$STAMP_DIR/receipt.json"
BASELINE="$STAMP_DIR/baseline.json"
QUICK=0
DOMAIN="full"
for _a in "$@"; do
case "$_a" in
--quick) QUICK=1 ;;
--code|--docs)
[ "$DOMAIN" = "full" ] || { echo "用法: --code 与 --docs 互斥"; exit 2; }
DOMAIN="${_a#--}" ;;
*) echo "用法: tools/run_all_gates.sh [--code|--docs|--quick]"; exit 2 ;;
esac
done
# 控制台编码:本仓库闸件输出中文与 ✅/✗ 符号。Windows GBK 控制台下 python 打印这些字符会抛
# UnicodeEncodeError——06/10 两闸直接崩(假红)、09 的 FAIL 行被写成乱码字节(指纹漂移,
# 与基线比必判「红项清单变化」假红)、末尾汇总自身崩掉打不出结论。2026-10-16 实踩。
export PYTHONIOENCODING=utf-8
# 回执与日志是中间产物(不入库),但 .gitignore 本身必须入库——漏了就等于把它们纳入版本管理。
# 每次跑批前自愈重建,避免「清目录重跑」把 .gitignore 一起带走(2026-10-15 实踩)。
mkdir -p "$STAMP_DIR"
if [ ! -f "$STAMP_DIR/.gitignore" ]; then
printf '# 本目录只入库 baseline.json(存量基线,需随代码走以保证各 worktree 判定口径一致)。\n# receipt.json 是每跑批覆写的当次回执、*.log 是跑批当场日志——两者都是中间产物,不入库。\nreceipt.json\n*.log\n' \
> "$STAMP_DIR/.gitignore"
echo "[gates] 已自愈重建 $STAMP_DIR/.gitignore"
fi
# ── python 解释器解析:优先带 PIL 的 gamedev venv(渲染闸的 shot_checker/golden_compare 依赖),
# 回落 PATH 上的 python。渲染闸的三个脚本要 PIL,用错解释器会假红。
PY=""
for cand in "/d/dev/python/envs/gamedev/Scripts/python.exe" "$(command -v python 2>/dev/null)" "python"; do
[ -z "$cand" ] && continue
if "$cand" -c "import PIL" >/dev/null 2>&1; then PY="$cand"; break; fi
[ -z "$PY" ] && PY="$cand"
done
GODOT="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}"
# 把带 PIL 的解释器目录前置到 PATH——render_gate.sh / shot_checker.py / golden_compare.py /
# golden_board_crop.py 内部各自调 `python`,继承本 PATH;用错解释器会因缺 PIL 假红。
case "$PY" in
*/Scripts/python.exe|*/Scripts/python) export PATH="$(dirname "$PY"):$PATH" ;;
esac
# ── 域判定:这道闸属哪个域、本次跑不跑 ──
in_domain() { # in_domain <code|docs|both>
local dom="$1"
[ "$dom" = "both" ] && return 0
[ "$DOMAIN" = "full" ] && return 0
[ "$DOMAIN" = "$dom" ] && return 0
return 1
}
# ── 素材齐盘预检(2026-10-17 加)──
# game/assets/art/** 与一切 *.import 边车都不入 git(.gitignore 2026-09-29 裁定「素材不入 git」),
# 所以 git worktree 检出的工程天生缺全部美术素材:main_theme.tres 解析失败 → 按钮五个样式盒全空
# → 冒烟 V1 成片假红;渲染闸又会因素材缺位把两段 golden 直接跳过(见 render_gate.sh)。
# 缺位即拒跑——禁带着空盘跑完,把「什么都没比」的回执当全绿合回。
if in_domain code; then
_miss=""
for a in assets/art/ui/button_stone.png assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \
assets/art/board/flag_seal.png assets/art/character/character_hero_idle.png \
assets/art/board/terrain_wall_n_dungeon.png assets/art/board/terrain_floor_dungeon_1.png \
assets/art/board/terrain_wall_n_underdark.png assets/art/board/terrain_floor_underdark_1.png \
assets/art/board/terrain_wall_n_deep_underdark.png assets/art/board/terrain_floor_deep_underdark_1.png; do
[ -f "$GAME/$a" ] || _miss="$_miss $a"
[ -f "$GAME/$a.import" ] || _miss="$_miss $a.import"
done
[ -f "$GAME/.godot/uid_cache.bin" ] || _miss="$_miss .godot/uid_cache.bin(导入未刷)"
if [ -n "$_miss" ]; then
echo "[gates] 素材齐盘预检 FAIL — 本工作区缺:$_miss"
echo " 处置:cp -r <主工作区>/game/assets/art/. <本区>/game/assets/art/"
echo " 再刷导入:$GODOT --headless --path <本区>/game --editor --quit"
echo " (--import 不生成新素材的 .import 边车,必须走 --editor --quit)"
exit 1
fi
echo "[gates] 素材齐盘预检 PASS(10 件素材+边车+导入缓存在位)"
fi
declare -a NAMES=() EXITS=() LOGS=()
FAILED=0
echo "重闸跑批 · 域=$DOMAIN · 仓库 $REPO"
echo "起跑 commit: $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '非git目录')"
echo "python: $PY"
echo "godot: $GODOT"
# ── 闸件执行:每道闸的输出落独立日志,供基线比对取指纹 ──
gate() { # gate <名称> <日志名> <命令...>
local name="$1" log="$2"; shift 2
echo ""
echo "════════════════════════════════════════════"
echo " $name"
echo "════════════════════════════════════════════"
"$@" >"$STAMP_DIR/$log" 2>&1
local rc=$?
# 失败时把关键行回显到终端(红必须看得见)
if [ $rc -ne 0 ]; then
grep -E "FAIL|红|Error|error" "$STAMP_DIR/$log" | tail -12 | sed 's/^/ | /'
fi
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("$log")
return 0
}
gate_unit() { # run_all 是 --script 模式,退出码为唯一判据
local name="02 单元套件"
echo ""
echo "════════════════════════════════════════════"
echo " $name(--script 模式,退出码为唯一判据)"
echo "════════════════════════════════════════════"
( cd "$REPO" && "$GODOT" --headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd ) \
>"$STAMP_DIR/unit.log" 2>&1
local rc=$?
# 三硬闸之二(脚本错误扫描):子套件的 SCRIPT ERROR/Parse Error 只写 stderr,入口内收不到,
# 只能在编排层扫本闸日志。命中即红——此类错误不改退出码、且会中断所在函数的剩余断言。
if [ "$rc" -eq 0 ] && ! "$PY" "$REPO/tools/error_scan.py" "$STAMP_DIR/unit.log" >>"$STAMP_DIR/unit.log" 2>&1; then
rc=1
tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /'
fi
[ $rc -ne 0 ] && tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /'
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log")
return 0
}
# ══ 代码域(1–5)══
if in_domain code; then
# ── 1. 系统级断言面(冒烟三闸:退出码+摘要失败数+SCRIPT ERROR 扫描,判定无洞)──
gate "01 冒烟全量" "smoke.log" "$PY" "$REPO/tools/run_smoke.py"
# ── 2. 单元套件 ──
gate_unit
# ── 3. 场景探针 · 行为走查段 ──
gate "03 探针 --click" "probe_click.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --click
# ── 4. 场景探针 · 断言段(iter27 漏跑的就是这一段)──
gate "04 探针 --full" "probe_full.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --full
# ── 5. 渲染面(窗口化,依赖 PIL)──
if [ $QUICK -eq 0 ]; then
gate "05 渲染闸" "render.log" bash "$REPO/tools/render_gate.sh" "$GAME"
else
NAMES+=("05 渲染闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 05 渲染闸 → SKIP(--quick)"
fi
fi
# ══ 文档域(6–8b)══
if in_domain docs; then
# ── 6. 文档三闸 ──
gate "06 gdd_lint" "gdd_lint.log" "$PY" "$REPO/tools/gdd_lint.py"
gate "07 doc_link" "doc_link.log" "$PY" "$REPO/tools/doc_link_audit.py" --strict
gate "08 doc_term" "doc_term.log" "$PY" "$REPO/tools/doc_term_audit.py" --strict
# ── 6b. 状态文件体量闸(active.md 只记现在:条目数/快照行数/编号/死指针四条硬上限)──
gate "08b active.md 体量" "active_state.log" "$PY" "$REPO/tools/audit_active_state.py"
fi
# ── 6c. 回放确定性对拍(条件闸件——迭代 29 T3 收编;属代码域)──
if in_domain code; then
# 触面判定:本批 diff 相对 master 分叉点,命中下列路径面之一即「触面」。
# 触面也**不自动跑**——对拍调的是 MC 脚本,属「MC/统计类跑批发起前须用户批准」红线管辖;
# 闸只负责每次跑批都把触面判定做一遍并显式要求批准,杜绝「该跑却没人记得」。
# 用户批准后设 PARITY_APPROVED=1 重跑本脚本即实跑三变体对拍。
PARITY_TOUCH_FACES="
game/data/tables/
game/core/rng_service.gd
game/core/save_manager.gd
game/core/meta_progression.gd
game/core/settlement/
game/core/damage_math.gd
game/core/damage_controller.gd
game/core/battle_orchestrator.gd
game/smoke/adventure_monte_carlo.gd
"
_base="$(git -C "$REPO" merge-base master HEAD 2>/dev/null || true)"
_changed_files="$(git -C "$REPO" diff --name-only "${_base:-HEAD}" HEAD 2>/dev/null || true)"
_touch_hit=0
for _f in $_changed_files; do
for _pat in $PARITY_TOUCH_FACES; do
case "$_f" in
"$_pat"*) _touch_hit=1 ;;
esac
done
done
_parity_name="11 回放确定性对拍"
if [ "$_touch_hit" -eq 0 ]; then
NAMES+=("$_parity_name"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] $_parity_name → SKIP(本批 diff 未触数值/存档/RNG/结算面)"
elif [ "${PARITY_APPROVED:-0}" = "1" ]; then
gate "$_parity_name" "parity.log" "$PY" "$REPO/tools/parity_anchor.py" check
else
NAMES+=("$_parity_name"); EXITS+=("NEED-APPROVAL"); LOGS+=("")
echo ""
echo "[gates] $_parity_name → 本批 diff 触及数值/存档/RNG/结算面,对拍需用户批准发起"
echo " (批准后设 PARITY_APPROVED=1 重跑本脚本即实跑;不得借机重锚)"
fi
fi
# ── 7. 静态扫描(代码域)+ 熵增闸(两域都跑:它查顶层目录/根文件/图像白名单/退役路径,
# 文档批新增或挪动 design/ 下的文件同样会绊它;纯文本扫描,秒级)──
if [ $QUICK -eq 0 ]; then
if in_domain code; then
gate "09 静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh"
fi
gate "10 熵增闸" "entropy.log" "$PY" "$REPO/tools/repo_structure_audit.py"
else
if in_domain code; then
NAMES+=("09 静态扫描"); EXITS+=("SKIP"); LOGS+=("")
fi
NAMES+=("10 熵增闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 09 静态扫描(代码域)/ 10 熵增闸 → SKIP(--quick)"
fi
# ── 与存量基线比对 ──
"$PY" - "$BASELINE" "$RECEIPT" "$QUICK" "$DOMAIN" "$REPO" "${NAMES[@]}" -- "${EXITS[@]}" -- "${LOGS[@]}" <<'PYEOF'
import hashlib, json, os, subprocess, sys
baseline_path, receipt_path, quick, domain, repo = sys.argv[1:6]
rest = sys.argv[6:]
sep = rest.index("--")
names = rest[:sep]
tail = rest[sep + 1:]
sep2 = tail.index("--")
exits = tail[:sep2]
logs = tail[sep2 + 1:]
stamp_dir = os.path.dirname(receipt_path)
def head():
try:
return subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=5).stdout.strip()
except Exception:
return "unknown"
def fingerprint(exit_code, log):
"""红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声。
绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。"""
# SKIP / NEED-APPROVAL 都属「本闸未实跑」状态,指纹一律记 skip——
# 两者互变不是闸况变化(同是没跑),只有「未跑 → 跑出结果」或「绿 → 红」才算变化。
if exit_code in ("0", "SKIP", "NEED-APPROVAL"):
return "green" if exit_code == "0" else "skip"
if not log:
return "red-nolog"
p = os.path.join(stamp_dir, log)
if not os.path.exists(p):
return "red-nolog"
keys = []
with open(p, encoding="utf-8", errors="replace") as f:
for line in f:
s = line.strip()
if not s or "FAIL" not in s:
continue
keys.append(s[:160])
if not keys:
return "red-nokeys"
return hashlib.sha1("\n".join(sorted(set(keys))).encode("utf-8")).hexdigest()[:12]
now = {}
for n, e, l in zip(names, exits, logs):
now[n] = {"exit": e, "fp": fingerprint(e, l)}
first_run = not os.path.exists(baseline_path)
if first_run:
with open(baseline_path, "w", encoding="utf-8") as f:
json.dump({"note": "存量基线·首次自动生成;闸况变化时本脚本会判红,需人工确认后重生成",
"gates": now}, f, ensure_ascii=False, indent=1)
base = dict(now)
else:
with open(baseline_path, encoding="utf-8") as f:
base = json.load(f).get("gates", {})
changed, inherited_red, not_run = [], [], []
for n in names:
b, c = base.get(n), now[n]
if b is None:
changed.append("%s:无基线记录(新增闸件)" % n)
continue
if b["exit"] == c["exit"] and b["fp"] == c["fp"]:
continue
if c["fp"] == "skip":
# 本闸这次没跑(--quick 跳过/条件闸件未触面)——没有结果可比,不判闸况变化。
# 2026-10-17 修:此前 SKIP 落进「基线绿 → 现红」分支,任何 --quick 回执都自带假红。
not_run.append(n)
continue
if b["exit"] != "0" and c["exit"] != "0" and b["fp"] == c["fp"]:
continue # 同款存量红,清单未变
if b["exit"] == "0" and c["exit"] != "0":
changed.append("%s:基线绿 → 现红(exit=%s)" % (n, c["exit"]))
elif b["exit"] != "0" and c["exit"] == "0":
inherited_red.append("%s:基线红 → 现绿(改善)" % n)
else:
changed.append("%s:红项清单变化(基线 %s → 现 %s)" % (n, b["fp"], c["fp"]))
stock_red = [n for n, e in zip(names, exits) if e not in ("0", "SKIP")]
all_green = not changed
with open(receipt_path, "w", encoding="utf-8") as f:
json.dump({
"commit": head(),
"commit_short": head()[:8],
"timestamp": subprocess.run(["date", "-Iseconds"], capture_output=True, text=True).stdout.strip(),
"quick_mode": quick == "1",
"domain": domain,
"first_baseline_run": first_run,
"gates": [{"name": n, "exit": e, "fingerprint": now[n]["fp"]} for n, e in zip(names, exits)],
"changed_vs_baseline": changed,
"inherited_red": inherited_red,
"not_run": not_run,
"all_green": all_green,
}, f, ensure_ascii=False, indent=1)
print("")
print("─" * 60)
if first_run:
print("首次跑批:已生成存量基线 %s" % baseline_path)
print("本次结果作为基线,后续与之比对。")
if changed:
print("【闸况变化,需处理】")
for c in changed:
print(" ✗ " + c)
if inherited_red:
print("【改善】")
for c in inherited_red:
print(" ↓ " + c)
if not_run:
print("【本次未跑(域外/--quick/未触面)】" + "、".join(not_run))
stock = [n for n, e in zip(names, exits) if e not in ("0", "SKIP")]
if stock:
print("【存量红·对基线零新增】" + "、".join(stock))
if all_green:
print("【验收通过】闸况与基线一致,无新增红。")
sys.exit(0)
sys.exit(1)
PYEOF
RC=$?
echo ""
echo "════════════════════════════════════════════"
printf ' 回执:%s\n 基线:%s\n commit:%s\n' "$RECEIPT" "$BASELINE" "$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')"
echo "════════════════════════════════════════════"
exit $RC
+2 -2
View File
@@ -35,7 +35,7 @@ def main():
ap.add_argument("--godot", default=DEFAULT_GODOT)
ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)")
ap.add_argument("--script", default=DEFAULT_SCRIPT)
ap.add_argument("--timeout", type=int, default=180, help="外部超时秒数(ADR-0004 硬性要求)")
ap.add_argument("--timeout", type=int, default=180, help="外部超时秒数(硬性要求)")
args = ap.parse_args()
cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script]
@@ -43,7 +43,7 @@ def main():
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=args.timeout)
except subprocess.TimeoutExpired:
print(f"[run_smoke] FAIL:外部超时(>{args.timeout}s——ADR-0004 硬性要求)")
print(f"[run_smoke] FAIL:外部超时(>{args.timeout}s——硬性要求)")
return 1
out = (proc.stdout or "") + (proc.stderr or "")
sys.stdout.write(out)
+36 -3
View File
@@ -8,10 +8,14 @@ r"""shot_checker.py — 整屏样张闸机检(迭代 9 卡⑤/T8 2026-09-30;
断言(整屏样张闸 §1.4 四项的机检可判面):
A 屏级骨架: PNG 存在/尺寸=工程档/灰度 std≥8/唯一色数≥64(渲染真实发生);
B 文字对比度: 已知文字框〔主墨/暖白 文字簇 vs 背景簇 p2/p98 极性无关〕WCAG ≥4.5:1
(菜单副标题+帮助文/战斗 C 区面板/结算明细提示——圣经 v3.5 §7.2 全局文字硬指标机检面);
(菜单副标题/战斗 C 区面板/结算明细提示——圣经 v3.5 §7.2 全局文字硬指标机检面;
原「菜单·帮助文」腿随载体玩法速览牌退役同撤——迭代 19 卡① S1 裁 2026-10-08);
C 明度三档带: 战斗屏 信息面(C 区面板均值) > 世界中(棋盘区均值) > 世界底(舞台无光柱区均值)
(§2 明度分层——层间灰度可分);
D 文字禁压纹理: 各文字框背景主簇纯度 ≥0.5(±12 灰度带占比——文字下垫平滑容器非 busy 纹理);
F 文字在判据: 菜单文字框内与中位灰度差 >60 的像素占比在 [2%, 55%] 带内(极性无关——亮字暗底/
暗字亮底同判;对比度只判反差不判「字在」,被埋菜单坐标处有反差即过线的缺口由本判据补,
provisional 阈值由 2026-10-15 实测定);
E 焦点预算〔首焦唯一〕: 语义面无法机检 → 编入段闸实机目检清单(T15 人闸判定)。
退出码: 0 = 全过 / 1 = 有失败。
"""
@@ -76,6 +80,32 @@ def check_text_box(img: Image.Image, box: tuple, name: str, failures: list) -> N
failures.append("%s: 背景纹理幅度 %.1f > 18(文字压 busy 纹理)" % (name, mid_std))
def check_text_presence(img: Image.Image, box: tuple, name: str, failures: list,
lo: float = 0.02, hi: float = 0.55, dev: int = 60) -> None:
"""文字在判据(迭代 28 T4——对比度只判反差不判「字在」)。
框内与中位灰度差 >dev 的像素占比须落在 [lo, hi] 带内:极性无关(亮字暗底/暗字亮底同判),
文字簇量级带(实测菜单标题 13.1%/副标题 11.5%——2026-10-15 窗口化实测定 provisional)。
出带=疑似空框/纯色块/文字被移出框(结构性移位)。
"""
if not box:
failures.append("%s: meta 几何缺失" % name)
return
px = sorted(box_pixels(img, box))
n = len(px)
if n == 0:
failures.append("%s: 空框(零像素)" % name)
return
med = px[n // 2]
ratio = sum(1 for v in px if abs(v - med) > dev) / n
ok = lo <= ratio <= hi
print("[shot_checker] %s — 文字像素占比=%.2f%%(带 [%.0f%%, %.0f%%] 极性无关)%s" % (
name, ratio * 100, lo * 100, hi * 100, "I-OK" if ok else "I-FAIL"))
if not ok:
failures.append("%s: 文字像素占比 %.2f%% 出带 [%.0f%%, %.0f%%](字在判据——疑似空框/纯色/文字移位)" % (
name, ratio * 100, lo * 100, hi * 100))
def main() -> int:
if len(sys.argv) < 2:
print("用法: python tools/shot_checker.py <截图目录> [屏清单...]")
@@ -130,8 +160,11 @@ def main() -> int:
failures.append("明度三档带: meta 几何缺失")
if "shot_menu.png" in imgs:
m = imgs["shot_menu.png"]
check_text_box(m, box_of(meta, "menu_sub"), "菜单·副标题", failures)
check_text_box(m, box_of(meta, "menu_help"), "菜单·帮助文", failures)
check_text_presence(m, box_of(meta, "menu_title"), "菜单·标题", failures)
# 菜单·帮助文断言腿退役(迭代 19 卡① S1 裁 2026-10-08——载体玩法速览牌 2026-10-06 用户令
# 退役「界面禁玩法文字墙」,锚/取样键/断言段三处同撤,不重锚)
# 菜单·副标题断言腿退役(2026-10-16 用户令——副标题件整个删除〔无意义标语〕,
# meta 键/断言两腿同撤,不重锚)
print("[shot_checker] 焦点预算〔首焦唯一〕= 目检项(T15 段闸人闸清单判定——语义面不机检)")
if failures:
print("[shot_checker] FAIL——")
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""verify_comment_only_change.py — 纯注释批次零行为变更机器证明。
对 git 工作区相对 HEAD 的每个改动 .gd(game/core、game/data、game/presentation):
剥离文件头注释区(首个非注释代码行前的全部空行/注释行)后,新旧剩余内容必须逐行一致——
证明改动只发生在文件头注释区,代码行为零变更。头注清偿/载体换代一类纯注释批的交付验收
配套(code-conventions §1.4.1)。
退出码:0=全部通过,1=存在头注区外的改动。用法:python tools/verify_comment_only_change.py
"""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def strip_header(lines):
"""剥掉头注区(首个非注释非空行前的全部空行/注释行),返回剩余行列表。"""
i = 0
while i < len(lines):
s = lines[i].strip()
if s == "" or s.startswith("#"):
i += 1
else:
break
return lines[i:]
def main():
out = subprocess.run(
["git", "diff", "--name-only", "HEAD", "--", "game/core", "game/data", "game/presentation"],
cwd=ROOT, capture_output=True, text=True, check=True).stdout.split()
if not out:
print("[verify_comment_only] 无改动文件")
return 0
bad = []
for rel in out:
rel = rel.replace("\\", "/")
old = subprocess.run(["git", "show", "HEAD:" + rel],
cwd=ROOT, capture_output=True, text=True, check=True).stdout
new = (ROOT / rel).read_text(encoding="utf-8")
if strip_header(old.splitlines()) != strip_header(new.splitlines()):
bad.append(rel)
if bad:
for rel in bad:
print("[verify_comment_only] FAIL 头注区外内容被改动: " + rel)
return 1
print("[verify_comment_only] ok: %d 个改动文件头注区外逐行一致(零行为变更)" % len(out))
return 0
if __name__ == "__main__":
sys.exit(main())