#!/usr/bin/env python3 """P67(职业每日任务 UI 目标展示)证据分析:把 P66 定点的 TargetNpc/TargetTile 画到任务面板。 判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计): 1. 两帧 `p67-target` / `p67-wildcard`,panel 均为 `task`。 2. 带目标帧:template=帮工,target=`村民 `,整行含「 → 」且形如「帮工:进行中 → 村民 N」。 3. 无目标帧:target=``,整行逐字节等于旧文案「帮工:进行中」(不含箭头/目标名)。 4. 两帧 missing_glyph_slots=0(中文经 CjkGlyphAtlas 绘制,无不可渲染字符)。 5. 图集含新增字形 地 / 块 / →(读 Assets/cjk-glyphs.txt 独立核验)。 6. 两帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。 7. 逐帧「字形是否清晰可辨」的 vision 复核由 Hermes 侧完成,非本脚本判据。 Usage: python3 scripts/analyze-p67.py Exit 0 pass / 1 fail / 2 usage. """ import re import sys from pathlib import Path import numpy as np from PIL import Image SHOT_RE = re.compile( r"^p67-shot=(\S+) panel=(\S+) template=(\S+) target=(.*?) line=(.*?) missing_glyph_slots=(\d+)$", re.MULTILINE, ) FRAMES = ["p67-target", "p67-wildcard"] LEGACY_WILDCARD_LINE = "帮工:进行中" TARGET_LINE_RE = re.compile(r"^帮工:进行中 → 村民 \d+$") DARK_SIDE = 24 DARK_MAX = 8 WHITE_SIDE = 64 WHITE_MIN = 250 def max_block(rgba, side, predicate): h, w = rgba.shape[0], rgba.shape[1] if h < side or w < side: return 0 mask = predicate(rgba).astype(np.int64) integral = np.zeros((h + 1, w + 1), dtype=np.int64) integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1) best = 0 for y in range(0, h - side + 1): for x in range(0, w - side + 1): total = ( integral[y + side, x + side] - integral[y, x + side] - integral[y + side, x] + integral[y, x] ) best = max(best, int(total)) return best def main(): if len(sys.argv) != 3: print(__doc__) return 2 rec = Path(sys.argv[1]) out = Path(sys.argv[2]) log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace") repo_root = Path(__file__).resolve().parent.parent failures = [] lines = [ "P67(职业每日任务 UI 目标展示)证据分析 — 真实 Desktop 实跑 xvfb 1280x720", "钩子:LV_P67_SHOT=1 LV_LEGACY_MAP=1(农夫真实开局),2 帧:p67-target / p67-wildcard", "路径:Kernel 每日任务纯函数生成 DailyTask → 任务面板展示(有目标 / 无目标各一帧)", "", ] shots = {mm.group(1): mm for mm in SHOT_RE.finditer(log)} for name in FRAMES: if name not in shots: failures.append(f"log missing p67-shot={name}") else: lines.append("log: " + shots[name].group(0)) lines.append("") if "p67-target" in shots: _, panel, template, target, line, slots = shots["p67-target"].groups() lines.append(f"target frame: panel={panel} template={template} target={target!r} line={line!r} slots={slots}") if panel != "task": failures.append(f"p67-target panel {panel!r} != task") if template != "帮工": failures.append(f"p67-target template {template!r} != 帮工") if not target.startswith("村民 "): failures.append(f"p67-target target {target!r} lacks 村民 ") if not TARGET_LINE_RE.match(line): failures.append(f"p67-target line {line!r} != 帮工:进行中 → 村民 N") if int(slots) != 0: failures.append(f"p67-target missing_glyph_slots {slots} != 0") if "p67-wildcard" in shots: _, panel, template, target, line, slots = shots["p67-wildcard"].groups() lines.append(f"wildcard frame: panel={panel} template={template} target={target!r} line={line!r} slots={slots}") if panel != "task": failures.append(f"p67-wildcard panel {panel!r} != task") if template != "帮工": failures.append(f"p67-wildcard template {template!r} != 帮工") if target != "": failures.append(f"p67-wildcard target {target!r} != ") if line != LEGACY_WILDCARD_LINE: failures.append(f"p67-wildcard line {line!r} != legacy {LEGACY_WILDCARD_LINE!r}") if "→" in line: failures.append("p67-wildcard line must not contain target arrow") if int(slots) != 0: failures.append(f"p67-wildcard missing_glyph_slots {slots} != 0") lines.append("") glyphs = repo_root / "src" / "LivingVillage.Desktop" / "Assets" / "cjk-glyphs.txt" table = set(glyphs.read_text(encoding="utf-8").splitlines()) if glyphs.exists() else set() missing_glyphs = [c for c in "地块→" if c not in table] lines.append(f"atlas glyphs: 地块→ present={not missing_glyphs} total={len(table)}") if missing_glyphs: failures.append(f"atlas missing new glyphs: {''.join(missing_glyphs)}") lines.append("") stats = {} for name in FRAMES: path = rec / f"{name}.png" if not path.exists(): failures.append(f"missing frame {name}.png") continue rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16) rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8) h, w = rgb.shape[0], rgb.shape[1] mean = float(rgb.mean()) std = float(rgb.std()) dark = max_block( rgba, DARK_SIDE, lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250), ) white = max_block( rgba, WHITE_SIDE, lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250), ) stats[name] = rgb lines.append(f"{name}.png {w}x{h} mean={mean:.1f} std={std:.1f} max_dark_block={dark} max_white_block={white}") if (w, h) != (1280, 720): failures.append(f"{name} size {w}x{h} != 1280x720") if std < 5.0: failures.append(f"{name} near-blank std={std:.2f}") if dark != 0: failures.append(f"{name} dark block {dark}") if white >= WHITE_SIDE * WHITE_SIDE: failures.append(f"{name} fully-white block {white}") lines.append("") if "p67-target" in stats and "p67-wildcard" in stats: a = stats["p67-target"].astype(np.int32) b = stats["p67-wildcard"].astype(np.int32) mean_diff = float(np.abs(a - b).mean()) diff_pixels = int((a != b).any(axis=2).sum()) lines.append( f"p67-target vs p67-wildcard differing_pixels={diff_pixels} mean_abs_diff={mean_diff:.3f}" ) if diff_pixels < 100: failures.append( f"target and wildcard frames differ in only {diff_pixels}px (<100): target text not drawn" ) # 差异必须落在任务面板任务行带内(面板 x16..,任务行约 y 637),证明确实是目标文案被画出。 band = (a != b).any(axis=2)[620:662, 0:400] band_pixels = int(band.sum()) lines.append(f"target-line band (x0..400, y620..662) differing_pixels={band_pixels}") if band_pixels < 50: failures.append( f"target-line band has only {band_pixels}px differing (<50): target text not in panel line" ) lines.append("") lines.append("缺字空白位 total=0(两帧 missing_glyph_slots=0;P67TaskTargetUiTests 断言字形覆盖)") lines.append("") verdict = "PASS" if not failures else "FAIL" lines.append(f"verdict: {verdict}") for f in failures: lines.append(f"FAIL: {f}") out.write_text("\n".join(lines) + "\n", encoding="utf-8") print("\n".join(lines)) return 0 if not failures else 1 if __name__ == "__main__": sys.exit(main())