docs(审计): 代码架构与实现质量审计报告入库(2026-10-14)

This commit is contained in:
DKChan committed 2026-10-14 00:53:08 +08:00
1 parent 97de02c688
commit 4dbb2b9f9e
1 file changed
+132
@@ -0,0 +1,132 @@
# 代码架构与实现质量审计报告(2026-10-14)
**审计对象**:`game/` 目录全部实现(约 114 个 GDScript 脚本 / 3.4 万行,不含 tests)
**审计方法**:godot-master 技能库(GD-Agentic-Skills 的本地镜像,Godot 4.7+ 专家知识库)双席审计——甲席走 Analyst 工作流审架构,乙席走 Auditor 工作流审实现规范与设计模式;两席均为**只读静态实读**,未运行游戏。实现席实跑了技能库自带扫描器(信号审计 / 类型标注审计),内存类扫描器需 Godot 运行时,按只读纪律跳过并注明。
**审计日期**:2026-10-14(基线 master 97de02c)
**基线事实**:全项目真实游戏场景只有 1 个(`presentation/main.tscn`,其余 13 个 .tscn 全在 smoke/ 下是测试探针);节点几乎全部由 GDScript 程序化构建,产品代码 `instantiate()` 调用数为 0;autoload 仅 2 个(ConfigDB / RngService);目录分层 core / data / presentation / assets / tests / smoke。
---
## 总评
**core 层实现质量显著高于垂直切片平均水准,两席审计独立确认核心域健康**;问题集中在场景层与生命周期层。两个 P0 是同一根因的两面:**整条应用生命周期(菜单/营地/冒险/战斗/每局复位)压缩在 main.gd 一个 1385 行的组合根里,靠手工清单维持局间隔离**——领域侧已经长出三层编排器(AdventureRunner 跨局持久 / LevelRunner 一关一生 / BattleOrchestrator 一战一生),场景层没有任何切缝与之对应。一句话定性:**领域分层是洋葱,场景组织是石头。**
---
## P0 发现(2 条,同根)
### P0-1 全产品单场景:场景作为「组合单元/生命周期单元」完全缺席
- **证据**:`project.godot`(main_scene 唯一);`presentation/main.tscn` 仅 1 个 Control 节点挂 main.gd;`presentation/main.gd:204-361` `_build_ui` 程序化构建全部四区 + 12 个模态层。
- **为什么是问题**:Godot 中场景是复用、组合与生命周期的基本单元——节点进/出树(`_enter_tree`/`_exit_tree`/NOTIFICATION_READY)本可免费承载「每局/每屏」的状态建立与销毁。现在一局结束什么都不销毁,全靠手动清理;任何视图都无法 F6 独立运行或单独复用;13 个 smoke 探针里 10 个只能挂载整个 main.tscn 才能测任何 UI(`smoke/ui_probe.gd:15` 等)。
- **修复方向**:按「屏」切场景,第一步只拆屏容器 .tscn,视图继续代码构建,不要求全面 .tscn 化。
### P0-2 每局状态靠手工复位清单,无局级生命周期边界
- **证据**:`presentation/main.gd:647-657`(`_on_battle_end_continue` 手工置空 runner/settlement/adventure/_adventure_mode 并调 debug_overlay.reset_sliders());`main.gd:555-559` `_reset_run_scope` 只清 3 项;局态旗至少 7 个散布类顶(`main.gd:53-56,76-78,129-131`)。这类手工清单已有前科:`main.gd:790-792` 注释记录了续玩分支 runner 未就绪导致的实机崩值。
- **为什么是问题**:场景机制「打包一组节点,`free()` 即整体回收」正是为消灭这种清单而存在;清单是随新增状态线性膨胀的手工契约,每加一个跨局字段就多一个静默泄漏点,且泄漏表现为低频难复现的脏状态而非显式报错。
- **修复方向**:把「一局」物化为可整体 `queue_free()` 的节点(或至少一个 run-scope 对象持全部局态),终局释放即复位。
---
## P1 发现(6 条)
### P1-1 战斗结算巨函数:259 行、嵌套 7 层、同构兑付块重复 ≥5 处(双席独立命中同一病灶)
- **证据**:`core/battle_orchestrator.gd:632` `_settle()` = 259 行;`:562-563` 嵌套达 7 层(该文件 ≥5 层缩进行共 80 行,全项目最高)。重复块实读确认:`counter_add` 兑付三行组 ×5(`:682-684, 741-743, 776-778, 838 附近, 1192-1193`);`grant_material` 二次兑付块 ×3(约 `:688-694, :789-792, :1206-1212`);`reveal_tiles` 检查 ×3。效果 kind 字符串分派(`get("kind","") ==`)散布 20 处于结算段内。姊妹病灶:`core/damage_controller.gd:272` `_resolve_hero_attack()` = 148 行。全文件 1725 行、类级状态约 40 个变量(:62-98)、fan-out 13 个 core 类。
- **为什么是问题**:编排器**不是**纯上帝对象(伤害已单据化进 DamageController,敌人/状态/奖励各自成类),但效果引擎只做了「匹配」数据驱动,「施加」按既定裁决留在发射点——实际效果是新增一种 effect kind 要同时改引擎词表 + 编排器内联分支,编排器增量复杂度与内容量线性挂钩。
- **修复方向**:不推倒重写——五段结算各抽成 Segment 类(grant/aura/combat/decay/verdict),编排器退为段序编排 + 行发射,20 处 kind 分支随段归位。
### P1-2 刷新次数双账本——所有权三问(谁拥有/谁可改/谁知道)不及格
- **证据**:权威计数在 `core/battle_orchestrator.gd:73`(`_refresh_used_this_round`,受理门槛 :1522);呈现/遥测另计一份 `presentation/main.gd:129`(`_refresh_used_round`),HUD 余量 = 表上限 − main 自己的计数(`main.gd:1240`)。
- **为什么是问题**:core 是裁判、main 是记账员,两条路一旦分叉(新增受理路径、调试注入、断点恢复),HUD 显示与真实可刷次数静默不一致。
- **修复方向**:`presentation_snapshot()` 已可携带本轮已刷次数——删 main.gd 副本,HUD 只读快照。
### P1-3 GP 影子账本:表现层持有权威态镜像,根因在 summary 载荷不全
- **证据**:`presentation/gp_shadow_ledger.gd:9-19`(表现侧镜像钱包余额);根因链:`core/adventure_runner.gd:1046`(close_wallet() 清零先于 adventure_finished.emit)+ `adventure_runner.gd:1022-1038`(summary 十一个键里没有 final GP),呈现层被迫在清账前从事件流自抄一份。
- **为什么是问题**:表现层持久化核心态违反「表现禁存数据」;镜像与权威只靠 wallet_changed 事件序维持一致(影子账本文件头自己承认是手工镜像)。该上抛的数据应随信号载荷走,不该让下游自建账本补抄。
- **修复方向**:summary 增加 `gp_final` 键(清账前快照),删影子账本。
### P1-4 共享可变字典跨三层:所有权「名义单点、实际三处可写」
- **证据**:`core/level_runner.gd:501-521` begin_battle 的 encounter 载荷携带 bonus_ref/adventure_counters/attune_charges/vision_counter 四个共享 dict 引用;`core/battle_orchestrator.gd:79,86-88` 持引用直写(_attune_charges_ref 注释自认「use_treasure 消费直改」);`level_runner.gd:50` 账本同为字典引用共享。
- **为什么是问题**:这是确定性设计(回灌/导出时点已文档化),但共享可变引用在函数签名上不可见——「冒险加算区归 AdventureRunner 所有」只是头注契约,LevelRunner 与 BattleOrchestrator 都握着同一本账可写。
- **修复方向**:共享账收拢为显式 Ledger 类,战斗侧只拿 read+append 原语;战末 export/开局 seed 已有的时点语义不变。
### P1-5 组合根(main.gd)不可独立测试:核心读面靠注释纪律维持
- **证据**:控制器不可独立实例化——`presentation/curio_interact_controller.gd:39-57` 构造 16 参(10 个组合根 Callable)、`presentation/camp_shop_presenter.gd:25-40` 13 参;测试代价:`tests/unit/presentation/pacing_queue_test.gd:17-18` 必须 load 整个 main.tscn 挂整树并篡改 main 内部字段才能驱动。25 个测试套件中 core/data 侧 22 个直接 new() 即测,presentation 侧只剩整树集成一条路。
- **为什么是问题**:组合根本身没有单测缝;豁免面与 20+ 访问器(settlement.minesweeper.* 等)无结构强制,全靠 ADR 注释和评审盯。
- **修复方向**:把 main 的「模式状态机 + 输入锁 + 模态编排」抽成无 UI 纯逻辑类(依赖仅 ModalStack + 查询 Callable),控制器构造参数收拢为 context 对象——不改行为即可获得单测缝。**注:本条应在场景拆分落地后重新评估,main.gd 缩水后抽取面可能大幅变小。**
### P1-6 静态类型闸缺位:类型规范有文、机器无闸
- **证据**:无类型 Array/Dictionary 游戏代码 73 处(core 53 / data 9 / presentation 11,如 `core/adventure_runner.gd:78,95`、`core/effect_engine.gd:94-95`、`presentation/main.gd:69`);core 无返回类型函数 12 处(`core/reward_economy.gd:109,124,136,151` 等);无类型参数(`core/attunement.gd:12`、`core/board_generator.gd:18,83,284,334`)。`project.godot` 全文 36 行没有任何 gdscript warning 配置——`untyped_declaration` 默认 Ignore。
- **为什么是问题**:仓库 code-conventions §4.2 有明文(Dictionary 取值参与算术前显式声明类型),但无机器闸则规范只靠评审;Variant 容器在结算热循环里吃类型检查开销且放行拼写错。
- **修复方向**:project.godot 提升 warning 级别让存量显形,新码一律收口;存量 73 处随批清理。**本条必须在其他重构全部落地之后做**,否则新重构代码又会产生新违规。
---
## P2 卫生件(建议打包随批,不需逐条裁决)
1. **死代码 16 处**:`core/battle_orchestrator.gd:216` `_attune_row`、`core/damage_controller.gd:102` `_fx_dispatch`(活实现 _fx_fact 的退役孪生)、`core/board_data.gd:61` `clear_variants`、`core/effect_engine.gd:417` `combo_of`、`core/treasure_items.gd:68,83`、`core/minesweeper_rules.gd:41`、`core/feats_engine.gd:13,38`、`core/config_db.gd:253,273`、`core/save_manager.gd:419`、`presentation/curio_interact_controller.gd:58`、`presentation/views/stage_world.gd:17`、`presentation/views/curio_pick_overlay.gd:170`、`presentation/views/ui_kit.gd:486`。其中 **FeatsEngine 整文件零调用**(仅注释提及,`data/schemas/feat_table.gd:22` 自注已随重设计退役)但 `class_name` 仍全局注册。
2. **绝对路径取 autoload ×4**:`presentation/main.gd:174,190,481,1351` 用 `get_node("/root/…")` 字符串路径取 ConfigDB/RngService——Godot 4 有编译期注入的全局名,字符串路径改名/拼写零防护。
3. **字符串式 Callable ×9**:`core/battle_orchestrator.gd:105,390,391`、`core/damage_controller.gd:98`、`core/adventure_runner.gd:940,951`、`core/level_runner.gd:166`、`presentation/main.gd:191`——`Callable(self, "方法名字符串")` 绕过编译期方法存在性校验,应改 `self._method` 引用形式。
4. **裸字符串状态机 ×16**:`core/adventure_runner.gd:120,159,176,263,269,277,402,894,974,1049,1051,1071`(state = "idle"/"running"/"settling");`presentation/views/standee_body.gd:333,373,380,387`。本仓 `core/action_economy.gd:12-23` 已有「内部 enum + match + 外部 String 契约 getter」正解示范,应套用。
5. **百行级装配/校验长函数 17 个**:`presentation/main.gd:204` `_build_ui()` 160 行、`level_map_screen.gd:72` 145 行、`camp_overlay_view.gd:55` 142 行、`debug_overlay.gd:159` 114 行、schema 校验器群(treasure_item_table.gd:87 = 149 行等)。线性装配圈复杂度不高,但区块级改动每次都在巨函数里动刀。**注:main._build_ui 在场景拆分中自然解体,不必单独修。**
6. **ConfigDB 越权退出 + resolver 类型软环**:`core/config_db.gd:75-82` `_fatal` 直接 quit(1)/OS.crash(fail-fast 政策对,执行位置应归根——`main.gd:176-179` 已有同通道 fatal 屏);`config_db.gd:57-58` 持有并构造 ConfigDbResolver,resolver 反向 class_name 引用 ConfigDb——双向类型耦合,resolver 对门面改持 Callable 诊断通道即解(validator 已是此形态)。
7. **模态「开态=可见性」协议**:`presentation/modal_stack.gd:39-63` has_open() 靠遍历 visible 位;`main.gd:404-409` 输入锁 = 栈查询 + 三例外 + 排除式;`main.gd:407-408` 注释记录过「DS_DEBUG 默认开 × 遮挡复活 × has_open 组合 = 棋盘永久锁死」事故。12 个层的状态散布在各自 visible 位上,约束只存在于调用点拼装。修法:ModalStack 内聚「互斥域/排除组」概念,开态谓词随层登记。
---
## 用户场景预想评估(审计席专门裁断)
用户预想:开始菜单做根场景 + 营地/冒险分开 + 冒险内分关卡/战斗两大块。
**裁断:方向支持,三条全部成立;需三处修正、一处明确反对。**
1. **支持「开始菜单做根场景」**——修正:拆场景后 SaveManager/MetaProgression 不能再由 main.gd new() 出来(`main.gd:182-189`),必须提升为跨场景持有者(第三个 autoload 或根场景常驻子节点),否则换场景即丢档。RngService 已有先例,模式现成。
2. **支持「营地与冒险分开」**——营地全部读写已收口在 MetaProgression/SaveManager 访问器(camp_shop_presenter 零直写),营地当前只是覆盖层(camp_overlay),升格独立场景不碰任何 core 接口,切缝天然干净。
3. **支持「冒险内关卡/战斗分块」**——最有力的论据是域侧三层生命周期已经存在,场景层只是补镜像:`level_screen` 已是独立屏雏形(`main.gd:76,259-263`,屏级互斥单出口 `main.gd:978-990`),`_battle_root` 是现成战斗屏宿主(`main.gd:77,207`)。预想与代码现实几乎一一对齐。
4. **明确反对一条**:不要借机把 40+ 个 views/*.gd 转成 .tscn。「程序化构建」与「场景切分」是两个独立决策——.tscn 根节点 + `_ready` 程序化内容完全合法;全面 .tscn 化会破坏全部探针契约(`board_view.gd:8-14` 对探针零改造契约)且收益为零。
5. **迁移安全网**:13 个 smoke 探针中 10 个假设「挂载 main.tscn 即全应用」,拆分时这批探针的挂载假设同批迁移,它们就是切分作业的回归保障。建议时序:垂直切片验证期「只拆不重写」——root 壳 + menu.tscn + camp 场景 + adventure.tscn(内含 level_map/battle 两容器),新内容一律进新场景,战斗屏视图代码原样搬。
---
## 实现规范面补充结论与扫描器运行记录
**四大高危面几乎无坑、无 P0 命中**:信号面、Tween 生命周期、数值单一事实源、autoload 卫生。
**扫描器实跑记录**(实现席如实登记):
- 技能库 `auditor_audit_signals.py`(python 只读输出):74 处 lambda 连接(presentation 4 / smoke 62 / tests 8)。presentation 4 处逐一实读确认无泄漏(发射者与 lambda 同子树同生共死,refresh 先 queue_free 子树并清登记数组,`camp_overlay_view.gd:227-230`);smoke 62 处为一次性 headless 探针进程,不进游戏包。
- 技能库 `auditor_audit_type_hints.py`:229 处(untyped Array 209 / Dictionary 20 / string connect 0);按目录 core 53 / data 9 / presentation 11 / smoke 133 / tests 23。
- 手工 grep 等价检查(如实注明非扫描器):Godot 3 四参 connect 0;字符串 `.connect(` 0;`.disconnect(` 0;CONNECT_* 标志 0;绝对路径 get_node 4(见 P2-2);`Callable(self,"…")` 9(见 P2-3);call_deferred 10 处实读均为帧沿触发非初始化创可贴;await 1 处(dev 工具);全站 `_physics_process` 中 await 0;create_tween 20+ 处实读确认节点绑定+回调清理;print 3 处全在 dev 工具。
- 跳过并注明:`auditor_audit_memory_fragmentation.gd`、`auditor_purge_report_generator.gd` 需 Godot 运行时,按只读纪律未跑。
- 误报排除一例:battle_orchestrator 的 round_started/board_changed(:30-31)初判疑为死信号,实读确认 :447,488,1516 有发射、三层转发链全活,非发现。
---
## 做对了的事(防止报告纯负面失真)
1. **信号严格向上,core 零反向依赖**:全 core/ 无一处 presentation 引用、无 get_node/树访问;runner 转发族用命名方法 Callable(`core/adventure_runner.gd:918-924` 等,头注写明禁 lambda 捕获 self 成环);信号连接唯一归组合根(ADR-0001 落实到位)。
2. **Autoload 克制且纯净**:仅两个、纯数据/服务形态、无子节点无 UI 知识;RngService 统一单盐流 + 全事件日志(`core/rng_service.gd:95-108`)使全游戏同种子逐位复现——确定性架构的业内水准做法,也是本次全部重构验收手段的基础。
3. **效果引擎护栏设计**:事件/效果/事实/算子四词表封闭常量 + 加载期 schema 校验致命 + 组合规则成文(`core/effect_engine.gd:24-91,362-433`),失败模式 fail-fast 而非静默。
4. **读面成文快照收口**:presentation_snapshot()/diagnostic_snapshot() 成文出口替代链式穿透,协作对象私有化 + 明文豁免面——封装边界是写下来的契约而非默契。
5. **Tween 生命周期纪律全站一致**:节点绑定 create_tween + tween_callback(queue_free) + is_instance_valid 守卫 + rebuild 重入先杀——技能库最常判死刑的陷阱本项目零命中。
6. **测试策略真实分层**:25 个 headless 套件直接实例化 core 类、表值现读不写死;确定性 RNG 使「逐位一致」断言成为可能,这在随机系统里是稀缺的测试能力。
---
## 修复排期估算与裁决记录
**逐项估算**:场景拆分 + 局生命周期物化 + 探针迁移 ≈ 4-5d;`_settle` 拆段 ≈ 1.5d;共享账本 Ledger 类化 ≈ 1d;类型闸拉起 + 73 处收口 ≈ 1.5d;组合根可测化抽取 ≈ 1-1.5d(场景拆分后重估);模态协议内聚 ≈ 0.5-1d;卫生件包 ≈ 1.5d。**合计约 12-14 个工作日。**
**裁决记录(2026-10-14 用户)**:全部技术债修复**允许放行单一大迭代**,12-14 个工作日在允许时间范围内。
**批内执行顺序约束**(审计席建议):①场景拆分与局生命周期先行(P0 根因,且决定 main.gd 终态形状);②战斗域重构(_settle 拆段 + Ledger 类化)随后;③组合根抽取在场景拆分后重估再动;④类型闸收尾(其他重构落地后才拉得起 Error 级);⑤卫生件随批顺手带。最大两件(场景拆分动 main.gd 1385 行 / _settle 拆段动战斗结算心脏)各自需要全量冒烟回归,同批内应分波提交、逐波全绿,避免回归时二分定位跨双巨改。
**核心验收手段**:确定性 RNG 架构给了杀手级验收——同种子逐位一致回放(重构前后跑完整局输出逐位一致);场景批验收 = 10 个探针迁移后全绿 + run_all 25 套;战斗域批验收 = 逐位一致 + 冒烟全量。
**写卡前待裁三点**(grilling 时闭):①SaveManager/MetaProgression 升格形态(第三个 autoload vs 根场景常驻节点);②共享账本 Ledger 类接口口径;③`_settle` 段边界切法。