Merge branch 'master' into art-standee-20261006
# Conflicts: # design/art/art-bible.md
This commit is contained in:
commit
4a65f9eb6d
428 files changed
+25789
-32790
No files matched your search
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# 已知死链清单(doc_link_audit --known 用)——每行 <文件相对路径>|<目标>,命中的归"已知"类不算失败。
|
||||
# 登记纪律:只收「历史记载/版本行中的已退役件引用」类(历史成文不可动先例);活性指路句死链必须修文不进本清单。
|
||||
design/gdd/systems/表现与支撑/数据配置.md|smoke/config_fatal_probe.gd
|
||||
+78
-5
@@ -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"] != "疑似现行"]
|
||||
|
||||
|
||||
@@ -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
@@ -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} 字")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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/(提交说明须写明触发原因)"
|
||||
@@ -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())
|
||||
@@ -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
@@ -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 "======== 残留检查(批末闸) ========"
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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——")
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user