Files
gplan-2/tools/loop_dashboard.py
T

526 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
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 <path>
定位: 项目开发循环 = 「完善文档 → 更新垂直切片游玩体验 → 再完善文档」。
闭环状态分散在六处——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("game/"):
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'<span class="tag {cls}">{esc(bucket)}</span>'
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"<li>{esc(l.lstrip('- '))}</li>" for l in active["snapshot_lines"])
# 近期提交
commits = "".join(f"<li><code>{esc(l.split()[0])}</code> {esc(l[l.index(' ')+1:][:90])}</li>" for l in git_info["log"])
# 在飞分组
fly_rows = "".join(
f"<tr><td>{esc(k)}</td><td>{v}</td></tr>" for k, v in git_info["grouped"].most_common())
untracked = "".join(f"<li>{esc(u)}</li>" for u in git_info["untracked"]) or "<li class='dim'>无</li>"
# 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"<tr><td colspan='5' class='dim' style='background:#22252c'>{esc(group)}({len(group_docs)})</td></tr>")
for d in sorted(group_docs, key=lambda x: x["mtime"], reverse=True):
mark = ' <span class="dirty">在飞</span>' if d["dirty"] else ""
doc_rows.append(
f"<tr><td>{esc(d['name'])}{mark}</td>"
f"<td>{esc(d['version'])}</td><td class='dim'>{esc(d['vdate'])}</td>"
f"<td class='dim'>{esc(d['mtime'])}</td>"
f"<td class='dim'>{esc(d['title'][:40])}</td></tr>")
# 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"<li>{bucket_tag(i['bucket'])}{esc(i['system'])} · {esc(i['title'][:46])}</li>"
for i in items if i["bucket"] != "解决态残留")
resolved = [i for i in items if i["bucket"] == "解决态残留"]
res_note = (f"<p class='dim' style='margin:8px 0 0'>另有 {len(resolved)} 条解决态残留"
f"(按「解决即移除」口径待清)</p>" if resolved else "")
todo_cards.append(
f"<div class='card'><h3>{esc(loop)} · 开放 {open_n}/{len(items)}</h3>"
f"<ul>{lis}</ul>{res_note}</div>")
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"<tr><td>{esc(loop)}</td><td>{open_n}</td><td>{len(items)}</td></tr>"
# 07
verify_items = "".join(f"<li>{esc(i)}</li>" for i in verify["items"]) or "<li class='dim'>—</li>"
obligations = "".join(f"<li>{esc(o)}</li>" for o in verify["obligations"]) or "<li class='dim'>—</li>"
ref_rows = ""
for r in verify["refs"]:
verdict = '<span class="stale">已落后</span>' if r["stale"] else '<span class="ok">一致</span>'
ref_rows += (f"<tr><td>{esc(r['doc'])}</td><td>{esc(r['ref'])}</td><td>{esc(r['current'])}</td>"
f"<td>{verdict}</td></tr>")
if not ref_rows:
ref_rows = "<tr><td colspan='4' class='dim'>07 头部未解析出文档版本引用</td></tr>"
# 台账健康
if active["entries"]:
coll = (f"检出撞号 {', '.join(str(n) for n in active['collisions'])}(编号有效、历史不改写)"
if active["collisions"] else "无撞号")
ledger_html = (f"<p>台账区(「{esc(active['tail_head'])}」之后)最新编号 "
f"<b>{active['entries'][-1]}</b>,下一编号建议 <b>{active['next_number']}</b>;{esc(coll)}。</p>")
else:
ledger_html = "<p class='dim'>未解析出台账条目</p>"
# 路线图
route = "".join(f"<li>{esc(s)}</li>" for s in roadmap) or "<li class='dim'>—</li>"
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>迭代闭环仪表盘 · Dungeon Sweeper</title>
<style>{CSS}</style>
</head>
<body>
<main>
<h1>迭代闭环仪表盘</h1>
<div class="meta">阶段: {esc(stage)} · 生成于 {now} · 只读视图,重跑 <code>python tools/loop_dashboard.py</code> 刷新</div>
<h2>当前状态快照(active.md)</h2>
<div class="card"><ul>{snap}</ul></div>
<h2>迭代闭环四问</h2>
<div class="grid">
<div class="card"><h3>① 什么在飞(git 未提交 {len(git_info['dirty'])} + 未跟踪 {len(git_info['untracked'])})</h3>
<table><tr><th>分组</th><th>文件数</th></tr>{fly_rows}</table>
<p class="dim" style="margin:8px 0 0">未跟踪: </p><ul>{untracked}</ul></div>
<div class="card"><h3>② 什么待拍板 / 待执行(06 清单 {len(todos)} 条)</h3>
<p>待办 {buckets.get('待办', 0)} · 部分收口 {buckets.get('部分收口', 0)} · 拍板待执行 {buckets.get('拍板待执行', 0)} ·
<span class="stale">解决态残留 {buckets.get('解决态残留', 0)}</span>(按「解决即移除」口径待清)</p>
<table><tr><th>循环</th><th>开放</th><th>总条数</th></tr>{"".join(loop_rows)}</table>
<p class="dim" style="margin:8px 0 0">完整清单见下方「待办清单全目」专区。</p></div>
<div class="card"><h3>③ 什么要人玩(07 体验验证 {len(verify['items'])} 项)</h3>
<ul>{verify_items}</ul></div>
<div class="card"><h3>④ 验证是否落后于文档(失效面检测)</h3>
<p class="dim">07 头部批次来源: {esc(verify['source_line'][:120])}</p>
<table><tr><th>文档</th><th>07 引用</th><th>现行版本</th><th>判定</th></tr>{ref_rows}</table>
{'<p class="stale">存在落后引用——07 观测项基于旧行为,验收读数以重测批义务为准。</p>' if stale_refs else '<p class="ok">引用与现行版本一致。</p>'}
<h3 style="margin-top:12px">重测批义务(07 原文)</h3>
<ul>{obligations}</ul></div>
</div>
<h2>GDD 文档新鲜度({len(docs)} 份,按改动时间)</h2>
<table><tr><th>文档</th><th>版本</th><th>版本日期</th><th>文件改动</th><th>标题</th></tr>{"".join(doc_rows)}</table>
<h2>待办清单全目(按循环)</h2>
<div class="grid">{"".join(todo_cards)}</div>
{'<div class="card" style="margin-top:14px"><h3>解决态残留(按「解决即移除」口径应从 06 移除,去向归台账与 git 历史)</h3><ul>' + "".join(f"<li>{esc(t['system'])} · {esc(t['title'])}</li>" for t in resolved_all) + '</ul></div>' if resolved_all else ''}
<h2>路线图(production/roadmap.md)</h2>
<div class="card"><ol>{route}</ol></div>
<h2>台账健康</h2>
<div class="card">{ledger_html}</div>
<h2>近期提交</h2>
<div class="card"><ul>{commits}</ul></div>
<footer>
数据源: production/session-state/active.md · production/roadmap.md · production/stage.txt ·
design/gdd/06-待办清单.md · design/gdd/07-体验验证清单.md · design/gdd/** · git status/log。
生成工具 tools/loop_dashboard.py;六处信息源格式变更时同步改解析约定(见工具头注)。
</footer>
</main>
</body>
</html>"""
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()