#!/usr/bin/env python3 r"""loop_dashboard.py — 迭代闭环仪表盘生成器 用法: python tools/loop_dashboard.py # 生成 production/loop-dashboard.html + 控制台摘要 python tools/loop_dashboard.py --open # 生成后用系统默认浏览器打开 python tools/loop_dashboard.py --out 定位: 项目开发循环 = 「完善文档 → 更新垂直切片游玩体验 → 再完善文档」。 闭环状态分散在六处——active.md 快照节 / 台账 / GDD 版本史 / 06-待办清单 / 07-体验验证清单 / git 在飞改动——本工具把六处聚合成一个只读视图,每次迭代 收口后重跑一次即可看清: 走到哪了、什么在飞、什么待拍板、什么验证项已落后于文档改动。 只读工具: 不改任何被扫描的文件,产物只有仪表盘 HTML 本身。 解析约定(跟随仓库现行格式,格式变了这里要跟着改): - active.md 台账区 = 文件最后一个二级标题之后(新条目追加在文件末尾) - GDD 版本 = 「版本历史」表中版本号最大的一行(行按时间追加) - 06-待办清单条目 = 四级标题,状态 = 其下「**状态**:」行 - 07-体验验证清单头部「当前批次来源」行内引用的 vX.Y 与 GDD 现行版本比对, 得出「体验验证清单落后于文档改动」的失效面 """ import argparse import html import os import re import subprocess import sys from collections import Counter, OrderedDict from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent GDD_DIR = ROOT / "design" / "gdd" ACTIVE_MD = ROOT / "production" / "session-state" / "active.md" ROADMAP_MD = ROOT / "production" / "roadmap.md" TODO_MD = GDD_DIR / "06-待办清单.md" VERIFY_MD = GDD_DIR / "07-体验验证清单.md" STAGE_TXT = ROOT / "production" / "stage.txt" DEFAULT_OUT = ROOT / "production" / "loop-dashboard.html" LOOP_ORDER = ["顶层文档", "排雷循环", "战斗循环", "冒险循环", "表现与支撑"] # 状态分类前缀 → 桶。解决即移除(2026-09-18 新口径)下「已解决/已裁决」残留 = 清理债。 RESOLVED_PREFIX = ("已解决", "已裁决") PARTIAL_PREFIX = ("部分收口", "部分清偿", "已部分收口", "已部分清偿") OPEN_PREFIX = ("待办",) DECIDED_PREFIX = ("方向已拍板", "已拍板") # ---------------------------------------------------------------- git 侧 def git(args): try: return subprocess.run( ["git", "-c", "core.quotepath=false"] + args, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=ROOT, check=True, ).stdout except (subprocess.CalledProcessError, FileNotFoundError): return "" def classify_path(rel: str): rel = rel.replace("\\", "/") if rel.startswith("design/gdd/"): return "GDD 文档" if rel.startswith("design/"): return "设计侧其他(registry/ux/art/research)" if rel.startswith("prototypes/"): if rel.endswith(".gd"): return "切片代码" if rel.endswith(".tres"): return "数据表" return "切片其他" if rel.startswith("tools/"): return "工具" if rel.startswith("production/"): return "会话产物" return "其他" def scan_git(): dirty, untracked = [], [] for line in git(["status", "--porcelain"]).splitlines(): if not line.strip(): continue state, path = line[:2], line[3:].strip().strip('"') if state.startswith("??"): untracked.append(path) else: dirty.append((state.strip() or "M", path)) log = [l for l in git(["log", "--oneline", "-5"]).splitlines() if l.strip()] grouped = Counter(classify_path(p) for _, p in dirty) gdd_dirty = [p for s, p in dirty if classify_path(p) == "GDD 文档"] return { "dirty": dirty, "untracked": untracked, "log": log, "grouped": grouped, "gdd_dirty": gdd_dirty, } # ---------------------------------------------------------------- GDD 清单 VERSION_ROW_RE = re.compile(r"^\|\s*v?(\d+)\.(\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})") def parse_doc(path: Path): try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: text = path.read_text(encoding="utf-8", errors="replace") title = next((l.lstrip("# ").strip() for l in text.splitlines() if l.startswith("# ")), path.stem) rel = path.relative_to(ROOT).as_posix() parts = path.relative_to(GDD_DIR).parts group = parts[0] if len(parts) > 1 else "顶层文档" version, vdate = None, None in_hist = False for line in text.splitlines(): if line.startswith("## "): in_hist = "版本历史" in line continue if in_hist: m = VERSION_ROW_RE.match(line) if m: v = (int(m.group(1)), int(m.group(2))) if version is None or v > version: version, vdate = v, m.group(3) mtime = datetime.fromtimestamp(path.stat().st_mtime).strftime("%m-%d %H:%M") return { "path": rel, "name": path.stem, "title": title, "group": group, "version": f"v{version[0]}.{version[1]}" if version else "—", "vdate": vdate or "—", "mtime": mtime, } def scan_gdd(git_info): dirty_set = {p.replace("\\", "/") for p in git_info["gdd_dirty"]} docs = [parse_doc(p) for p in sorted(GDD_DIR.rglob("*.md"))] for d in docs: d["dirty"] = d["path"] in dirty_set return docs # ---------------------------------------------------------------- active.md LEDGER_ENTRY_RE = re.compile(r"^(\d+)\.\s+\*\*") def scan_active(): info = {"snapshot_lines": [], "updated_line": "", "next_line": "", "entries": [], "collisions": [], "next_number": None, "tail_head": ""} if not ACTIVE_MD.exists(): info["error"] = "active.md 不存在" return info lines = ACTIVE_MD.read_text(encoding="utf-8").splitlines() # 快照节(标题带纪律后缀,按关键词匹配) in_snap = False for line in lines: if line.startswith("## "): in_snap = "当前状态快照" in line if in_snap: continue if in_snap and line.strip(): info["snapshot_lines"].append(line.strip()) for ln in info["snapshot_lines"]: if ln.startswith("- **更新于**"): info["updated_line"] = ln if ln.startswith("- **下一步顺序**"): info["next_line"] = ln # 台账区 = 最后一个二级标题之后 last_h2 = max((i for i, l in enumerate(lines) if l.startswith("## ")), default=-1) tail = lines[last_h2 + 1:] info["tail_head"] = lines[last_h2].lstrip("# ").strip() if last_h2 >= 0 else "" numbers = [] for line in tail: m = LEDGER_ENTRY_RE.match(line) if m: numbers.append(int(m.group(1))) info["entries"] = numbers seen = Counter(numbers) info["collisions"] = sorted(n for n, c in seen.items() if c > 1) if numbers: info["next_number"] = max(numbers) + 1 return info # ---------------------------------------------------------------- 06 待办清单 def scan_todo(): items = [] if not TODO_MD.exists(): return items loop = system = "" cur = None for line in TODO_MD.read_text(encoding="utf-8").splitlines(): if line.startswith("## "): loop, system = line.lstrip("# ").strip(), "" elif line.startswith("### "): system = line.lstrip("# ").strip() elif line.startswith("#### "): cur = { "loop": loop, "system": system, "title": line.lstrip("# ").strip(), "status": "", } items.append(cur) elif cur is not None and line.startswith("**状态**"): cur["status"] = line.replace("**状态**", "").lstrip(":: ").strip() cur = None for it in items: s = it["status"] if s.startswith(RESOLVED_PREFIX): it["bucket"] = "解决态残留" elif s.startswith(PARTIAL_PREFIX): it["bucket"] = "部分收口" elif s.startswith(OPEN_PREFIX): it["bucket"] = "待办" elif s.startswith(DECIDED_PREFIX): it["bucket"] = "拍板待执行" else: it["bucket"] = "其他" return items # ---------------------------------------------------------------- 07 体验验证清单 VERSION_REF_RE = re.compile(r"(战斗结算|增益状态|地图战斗资源|敌人系统|行动经济|棋盘生成|排雷玩法|难度曲线与Boss|元进度|关卡节点地图|HUD框架|战斗表现|数据配置|存档序列化|音频|教学引导|设置与本地化|地形变异|冒险结构|魔法道具)\s*(v\d+\.\d+)") def scan_verify(docs): info = {"items": [], "obligations": [], "source_line": "", "refs": [], "error": None} if not VERIFY_MD.exists(): info["error"] = "07-体验验证清单.md 不存在" return info text = VERIFY_MD.read_text(encoding="utf-8") lines = text.splitlines() section = "" for line in lines: if line.startswith("## "): section = line.lstrip("# ").strip() continue if line.startswith("### ") and "观测项" in section: info["items"].append(line.lstrip("# ").strip()) elif line.startswith("- ") and "重测批义务" in section: info["obligations"].append(line[2:].strip()) m = re.search(r"当前批次来源:(.+)", text) if m: info["source_line"] = m.group(1).strip() by_name = {d["name"]: d for d in docs} for name, ver in VERSION_REF_RE.findall(info["source_line"]): cur = by_name.get(name) info["refs"].append({ "doc": name, "ref": ver, "current": cur["version"] if cur else "?", "stale": bool(cur and cur["version"] != "—" and cur["version"] != ver), }) return info # ---------------------------------------------------------------- 路线图 def scan_roadmap(): steps = [] if not ROADMAP_MD.exists(): return steps in_route = False for line in ROADMAP_MD.read_text(encoding="utf-8").splitlines(): if line.startswith("## "): in_route = "路线图" in line and "维护" not in line continue if in_route and re.match(r"^\d+\.\s", line): steps.append(line.strip()) return steps # ---------------------------------------------------------------- 控制台摘要 def print_summary(stage, git_info, docs, active, todos, verify, out_path): def p(s=""): print(s) p("═" * 62) p(f"迭代闭环仪表盘 · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 阶段: {stage}") p("═" * 62) if active.get("updated_line"): p(active["updated_line"].lstrip("- ")) g = git_info["grouped"] p(f"在飞改动: {len(git_info['dirty'])} 文件(" + " / ".join(f"{k} {v}" for k, v in g.most_common()) + f")+ 未跟踪 {len(git_info['untracked'])}") dirty_docs = [d for d in docs if d["dirty"]] versioned = [d for d in docs if d["version"] != "—"] newest = max(versioned, key=lambda d: d["vdate"], default=None) p(f"GDD: {len(docs)} 份,其中在飞 {len(dirty_docs)} 份" + (f";最近改版 {newest['name']} {newest['version']}({newest['vdate']})" if newest else "")) buckets = Counter(t["bucket"] for t in todos) p(f"待办清单: {len(todos)} 条(待办 {buckets.get('待办', 0)} / 部分收口 {buckets.get('部分收口', 0)}" f" / 拍板待执行 {buckets.get('拍板待执行', 0)} / 解决态残留 {buckets.get('解决态残留', 0)}" f"←按「解决即移除」口径应移除)") p(f"体验验证: {len(verify['items'])} 项待真人观测") if active["collisions"]: p(f"台账: 最新编号 {active['entries'][-1]},下一编号建议 {active['next_number']}" f"(撞号: {', '.join(str(n) for n in active['collisions'])})") else: p(f"台账: 最新编号 {active['entries'][-1] if active['entries'] else '—'},下一编号建议 {active['next_number']}") stale = [r for r in verify["refs"] if r["stale"]] if stale: p("⚠ 07-体验验证清单引用版本已落后:") for r in stale: p(f" {r['doc']}: 07 引 {r['ref']} / 现行 {r['current']}") if active.get("next_line"): p(active["next_line"].lstrip("- ")) p(f"已生成: {out_path.relative_to(ROOT).as_posix()}") p("═" * 62) # ---------------------------------------------------------------- HTML CSS = """ :root{--bg:#14161a;--card:#1d2026;--line:#2c3038;--tx:#d8dce3;--dim:#8b919c; --acc:#5b9dd9;--warn:#d9a05b;--bad:#d95b6a;--ok:#6fbf7f;} *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--tx); font-family:"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;font-size:14px;line-height:1.6} main{max-width:1080px;margin:0 auto;padding:24px 20px 60px} h1{font-size:22px;margin:0 0 4px} h2{font-size:17px;margin:36px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)} .meta{color:var(--dim);font-size:12.5px;margin-bottom:8px} .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start} @media(max-width:800px){.grid{grid-template-columns:1fr}} .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px 16px} .card h3{margin:0 0 8px;font-size:14px;color:var(--acc)} .card ul{margin:0;padding-left:18px} .card li{margin:3px 0} table{width:100%;border-collapse:collapse;background:var(--card); border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:13px} th,td{padding:6px 10px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top} th{color:var(--dim);font-weight:600;background:#22252c} tr:last-child td{border-bottom:none} .dirty{color:var(--warn);font-weight:600} .tag{display:inline-block;padding:0 8px;border-radius:10px;font-size:12px;margin-right:6px} .tag.open{background:#2b3a4a;color:#9cc7ec} .tag.partial{background:#463d2b;color:#e0c08a} .tag.decided{background:#3a3350;color:#c3b3ec} .tag.resolved{background:#3c2b30;color:#e0999f} .stale{color:var(--bad);font-weight:600} .ok{color:var(--ok)} .dim{color:var(--dim)} footer{margin-top:40px;color:var(--dim);font-size:12px;border-top:1px solid var(--line);padding-top:12px} code{background:#262a31;padding:1px 5px;border-radius:4px;font-size:12.5px} """ def esc(s): return html.escape(str(s)) def bucket_tag(bucket): cls = {"待办": "open", "部分收口": "partial", "拍板待执行": "decided", "解决态残留": "resolved"}.get(bucket, "partial") return f'{esc(bucket)}' def render_html(stage, git_info, docs, active, todos, verify, roadmap, out_path): now = datetime.now().strftime("%Y-%m-%d %H:%M") buckets = Counter(t["bucket"] for t in todos) dirty_docs = [d for d in docs if d["dirty"]] stale_refs = [r for r in verify["refs"] if r["stale"]] # 快照节 snap = "".join(f"
  • {esc(l.lstrip('- '))}
  • " for l in active["snapshot_lines"]) # 近期提交 commits = "".join(f"
  • {esc(l.split()[0])} {esc(l[l.index(' ')+1:][:90])}
  • " for l in git_info["log"]) # 在飞分组 fly_rows = "".join( f"{esc(k)}{v}" for k, v in git_info["grouped"].most_common()) untracked = "".join(f"
  • {esc(u)}
  • " for u in git_info["untracked"]) or "
  • 无
  • " # GDD 清单 doc_rows = [] for group in LOOP_ORDER: group_docs = [d for d in docs if d["group"] == group] if not group_docs: continue doc_rows.append( f"{esc(group)}({len(group_docs)})") for d in sorted(group_docs, key=lambda x: x["mtime"], reverse=True): mark = ' 在飞' if d["dirty"] else "" doc_rows.append( f"{esc(d['name'])}{mark}" f"{esc(d['version'])}{esc(d['vdate'])}" f"{esc(d['mtime'])}" f"{esc(d['title'][:40])}") # 06 待办 todo_by_loop = OrderedDict((g, []) for g in LOOP_ORDER) for t in todos: todo_by_loop.setdefault(t["loop"], []).append(t) todo_cards = [] for loop, items in todo_by_loop.items(): if not items: continue open_n = sum(1 for i in items if i["bucket"] in ("待办", "部分收口", "拍板待执行")) lis = "".join( f"
  • {bucket_tag(i['bucket'])}{esc(i['system'])} · {esc(i['title'][:46])}
  • " for i in items if i["bucket"] != "解决态残留") resolved = [i for i in items if i["bucket"] == "解决态残留"] res_note = (f"

    另有 {len(resolved)} 条解决态残留" f"(按「解决即移除」口径待清)

    " if resolved else "") todo_cards.append( f"

    {esc(loop)} · 开放 {open_n}/{len(items)}

    " f"{res_note}
    ") resolved_all = [t for t in todos if t["bucket"] == "解决态残留"] # 循环级计数(四问卡片②的汇总表) loop_rows = "" for loop, items in todo_by_loop.items(): if not items: continue open_n = sum(1 for i in items if i["bucket"] in ("待办", "部分收口", "拍板待执行")) loop_rows += f"{esc(loop)}{open_n}{len(items)}" # 07 verify_items = "".join(f"
  • {esc(i)}
  • " for i in verify["items"]) or "
  • —
  • " obligations = "".join(f"
  • {esc(o)}
  • " for o in verify["obligations"]) or "
  • —
  • " ref_rows = "" for r in verify["refs"]: verdict = '已落后' if r["stale"] else '一致' ref_rows += (f"{esc(r['doc'])}{esc(r['ref'])}{esc(r['current'])}" f"{verdict}") if not ref_rows: ref_rows = "07 头部未解析出文档版本引用" # 台账健康 if active["entries"]: coll = (f"检出撞号 {', '.join(str(n) for n in active['collisions'])}(编号有效、历史不改写)" if active["collisions"] else "无撞号") ledger_html = (f"

    台账区(「{esc(active['tail_head'])}」之后)最新编号 " f"{active['entries'][-1]},下一编号建议 {active['next_number']};{esc(coll)}。

    ") else: ledger_html = "

    未解析出台账条目

    " # 路线图 route = "".join(f"
  • {esc(s)}
  • " for s in roadmap) or "
  • —
  • " return f""" 迭代闭环仪表盘 · Dungeon Sweeper

    迭代闭环仪表盘

    阶段: {esc(stage)} · 生成于 {now} · 只读视图,重跑 python tools/loop_dashboard.py 刷新

    当前状态快照(active.md)

      {snap}

    迭代闭环四问

    ① 什么在飞(git 未提交 {len(git_info['dirty'])} + 未跟踪 {len(git_info['untracked'])})

    {fly_rows}
    分组文件数

    未跟踪:

      {untracked}

    ② 什么待拍板 / 待执行(06 清单 {len(todos)} 条)

    待办 {buckets.get('待办', 0)} · 部分收口 {buckets.get('部分收口', 0)} · 拍板待执行 {buckets.get('拍板待执行', 0)} · 解决态残留 {buckets.get('解决态残留', 0)}(按「解决即移除」口径待清)

    {"".join(loop_rows)}
    循环开放总条数

    完整清单见下方「待办清单全目」专区。

    ③ 什么要人玩(07 体验验证 {len(verify['items'])} 项)

      {verify_items}

    ④ 验证是否落后于文档(失效面检测)

    07 头部批次来源: {esc(verify['source_line'][:120])}

    {ref_rows}
    文档07 引用现行版本判定
    {'

    存在落后引用——07 观测项基于旧行为,验收读数以重测批义务为准。

    ' if stale_refs else '

    引用与现行版本一致。

    '}

    重测批义务(07 原文)

      {obligations}

    GDD 文档新鲜度({len(docs)} 份,按改动时间)

    {"".join(doc_rows)}
    文档版本版本日期文件改动标题

    待办清单全目(按循环)

    {"".join(todo_cards)}
    {'

    解决态残留(按「解决即移除」口径应从 06 移除,去向归台账与 git 历史)

      ' + "".join(f"
    • {esc(t['system'])} · {esc(t['title'])}
    • " for t in resolved_all) + '
    ' if resolved_all else ''}

    路线图(production/roadmap.md)

      {route}

    台账健康

    {ledger_html}

    近期提交

      {commits}
    """ def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") ap = argparse.ArgumentParser(description="迭代闭环仪表盘生成器") ap.add_argument("--out", default=str(DEFAULT_OUT)) ap.add_argument("--open", action="store_true", help="生成后用默认浏览器打开") args = ap.parse_args() stage = STAGE_TXT.read_text(encoding="utf-8").strip() if STAGE_TXT.exists() else "—" git_info = scan_git() docs = scan_gdd(git_info) active = scan_active() todos = scan_todo() verify = scan_verify(docs) roadmap = scan_roadmap() out_path = Path(args.out) out_path.write_text( render_html(stage, git_info, docs, active, todos, verify, roadmap, out_path), encoding="utf-8") print_summary(stage, git_info, docs, active, todos, verify, out_path) if args.open and sys.platform == "win32": os.startfile(str(out_path)) # noqa: 仅 Windows 便捷入口 if __name__ == "__main__": main()