diff --git a/CLAUDE.md b/CLAUDE.md index d14c0e3c..246d498c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ ## 运行与验证(工作目录 = 含 project.godot 的切片目录) - **交付前验收 = 跑 `tools/run_all_gates.sh`(不带 `--quick`),全绿才合回 master**——它是全部验证闸的唯一入口与唯一清单,闸件增减只改它,文档禁另列清单。跑完写回执 `game/tests/.gates/receipt.json`,合回时由 worktree-guard 校验(缺回执/quick 模式/回执 commit ≠ HEAD/含红项四况一律拦截) -- 单件直跑(调试期最窄确认用,**不构成验收**):冒烟 `python tools/run_smoke.py` · 场景探针 `…console.exe --headless --path . res://smoke/ui_probe.tscn` · 单元 `… --script res://tests/run_all.gd` +- 单件直跑(调试期最窄确认用,**不构成验收**):系统级断言面 `python tools/run_smoke.py`(名带 smoke 但不止存活检查,术语见 `game/tests/README.md`)· 场景探针 `…console.exe --headless --path . res://smoke/ui_probe.tscn` · 单元 `… --script res://tests/run_all.gd` - 调参批量:`--script res://smoke/adventure_monte_carlo.gd`(全冒险期望)· `res://smoke/reward_expectation.gd`(奖励期望);通关率/难度类测量一律走全冒险 - 数值验证:`python tools/balance_verifier.py`(采集端 `res://smoke/verifier_batch.gd`,报告落 `design/qa/`) - 熵增闸(新类别 = 同批修订 Project Layout + 白名单):`python tools/repo_structure_audit.py` diff --git a/active.md b/active.md index 5fddb21f..a9570083 100644 --- a/active.md +++ b/active.md @@ -16,21 +16,22 @@ - **工程路线图**:根目录 `roadmap.md`(里程碑时间线的权威;实时状态看下方快照节) ## 当前状态快照(收口批先更新本节,再追加状态记录;≤10 行) -- **更新于**:2026-10-16(迭代 29 编入测试分层与门禁语义修正批〔roadmap 21c83f5〕;M4 数值批已废、四项欠账归 06 在册待裁;**需求卡片已升 v2.0——三轮复查定稿、零待裁点**)。 -- **最新闸态**:迭代 28 批末冒烟 1125/0、run_all 27 套、probe --click 残留 0、render golden 棋盘×3+菜单 0.00%、doc 三闸与熵增对基线零新增、static_scan S9 归零(S10 存量 125 在文件头清偿批在册);MC 零跑。 +- **更新于**:2026-10-16(**迭代 29 测试分层与门禁语义修正批已入库**——三硬闸落地+L2/L3 建承载+L1 补洞六套+S3/S5 修复;冒烟 1127/0、run_all 33 套、轻闸探针 10 件、重闸全绿存量红零新增;M4 数值批已废、四项欠账归 06 在册待裁)。 +- **最新闸态**:迭代 29 批末冒烟 1127/0、run_all 33 套(含 1 集成)、probe --click 残留 0、render golden 棋盘×3 0.00%+菜单 0.15%、doc 三闸与熵增对基线零新增、static_scan S3/S5 新落地(S10 存量 127 在册);**11 号闸 parity 判为触面、待用户批准才发起**,MC 未跑。 - **probe --full 曾有一件红**:`t17_defer_probe`「关卡画面未出现」,用户已裁为存量红、不阻合并(合并 5b958ff6);根因是探针未隔离带断点的真实存档,该修复已随 `gatefix-20261015` 合入 master(合并 de8d28b6)。 - **迭代 28 交付**:修好启动时菜单被冒险层盖住的装配顺序,层序收敛为唯一装配合同(散落的两处 `move_child` 删除);boot 链路、九阶段生命周期、启动态菜单 golden 三组验收补全;本批新立硬规则「验收函数无变异红记录不算验收成立」,三处规约落文。 - **迭代 27 交付**:三屏壳场景与局态物化、结算五段拆分、RunLedger 单类持四账、模态编排类、类型闸、死代码退役,编排器由 1725 行降到 1325 行。 - **规约体系现状**:规则文件按领域拆为 7 份、单文件上限 150 行,CLAUDE.md 只留元信息、命令、目录、规范导航与一行一条红线;动手前按规范导航表查对应细则。 - **人闸在册两件**:迭代 27 全流程走查〔07 ㉖〕与迭代 28 启动态菜单〔07 ㉗〕,均登记不阻塞;贫矿脉目检项〔07 ⑪〕已随 2026-10-16 屏蔽令转记失效;迭代 19~26 的目检已于 2026-10-14 全部核销。 - **簿记出清**:迭代 19~26 的代拍板追认单与 GDD 反哺点已全部裁决落文,对应 14 份迭代实现报告按「证据只留最新」删除(git 历史可考)。 -- **已裁在册**:M4 数值批已废(2026-10-16 用户删卡令——禁止再以该名义立批/规划/引用已删卡,四项数值欠账归 06 在册、重新归属待裁);**迭代 29=测试分层与门禁语义修正批**(2026-10-16 用户令编入·**待开工**——需求卡片 **v2.0**(三轮复查定稿、**零待裁点**),开工门 U0=**仅欠 `/iter` 发令**);迭代 27/28 位预留追加卡。 +- **已裁在册**:M4 数值批已废(2026-10-16 用户删卡令——禁止再以该名义立批/规划/引用已删卡,四项数值欠账归 06 在册、重新归属待裁);**迭代 29=测试分层与门禁语义修正批 ✅ 已入库**(需求卡片 v2.0 零待裁点;收口遗留=11 号闸 parity 对拍待批准发起、GDD 反哺三条待裁、06 在册遗留七项);迭代 27/28 位预留追加卡。 - **界面美化三层**:第一层三件全部交付;余第二层三件(贴图/字体/样张)与第三层其余(九宫格切片/棋盘观感)待立项。 ## 未决面(进行中/待人闸/待裁点指针;现行事实源登记处=`design/gdd/06-待办清单.md`) - 待用户裁决(06 在册):迭代 27 GDD 反哺两条;迭代 28 GDD 反哺三条;美术审计两项 P0(art-bible 死样张指涉、主角像素管线重产);去毒残留词是否清扫(「魔法道具」约 30 处、「罕见」约 18 处);喂雷流 R5 甲重启条件回落文;「通关」与「通过第 N 关」全库口径统一(约 273 处);**贫矿脉屏蔽的设计语义面反哺五处**(地形变异.md §全局作用域型/HUD 框架三层预告/教学引导手册行/难度曲线与Boss 两行/art-bible §4.6.3 括注——均描述已撤回功能,待裁是否改写,机械面已同步)。 - 待用户追认:迭代 27 代拍板十五项、迭代 28 代拍板十三项(含四条白名单外),清单在两份现行实现报告的追认单节(`design/qa/iter27-实现报告-2026-10-14.md`、`design/qa/iter28-实现报告-2026-10-15.md`);req-card 技能的 WorkBuddy 与 ZCode 两份副本是否取齐待裁。 - 待发令:**迭代 29 测试分层与门禁语义修正批**(U0 门:Q3–Q7 裁点+卡片 v2.0 重核+`/iter` 发令);迭代 27/28 预留追加卡;黑话清扫第二波(GDD 正文流程动词族约三千处);规则文件编写 skill(按 497 批工作流提炼)。 +- **待合回(2026-10-16 用户裁)**:**迭代 29 分支 `iter29-testlayers-20261016` 已开发完成、暂不合回 master**——同步 master 后复跑重闸冒出两个非本批红(①渲染闸 `menu_boot.png` changed=4.61%,在主区同闸同样 4.61% ⇒ 来自 master 侧;②gdd_lint `01-核心循环.md` R5 缺流程图,master 提交已自陈为并行会话 `9846a29f` 既有问题)。待这两条在 master 上处理完再合回;**不代为刷新基线**(会固化真实视觉差异)。本批遗留另有:11 号闸 parity 对拍待批准发起。 - 在途分支未合入 master(读现状时勿当已入库):`art-standee-20261006`(artwork 独立仓 dry-run 报告+立绘定档,领先 5 笔)、`stc-norm-20261015`(GDD 用语规范批,领先 4 笔);`gatefix-20261015` 已合入 master(合并 de8d28b6);`ci-removal-20261015` **未合入**(4 项改动仍是 worktree 未提交状态——其 ADR 定位两条已被 2026-10-15「决策文档体系废除」批取代,仅 CI 清除部分对迭代 29 仍有效);`doc-adr-fixes-20261014` **方向已废**(ADR 四节重建/修订注记与用户「ADR 不需要保存」裁定相反,分支去留待用户裁)。 - 在册观察(06 登记,非阻塞):迭代 27 卡片义务补登两项——长函数余量 17 个中的装配/校验函数、lambda 连接防回归。 @@ -51,3 +52,7 @@ - **503(2026-10-16,贫矿脉屏蔽+DS_DEBUG 写死批总收口)**:用户令两项一波走完(worktree `g-plan2-shielddebug-20261016`,五提交,合并 d3356985,合并后主区冒烟 1126/0 兜底)。**①贫矿脉屏蔽**:`variation.tres` 删 skewed_veins 行(用户裁「地形变异特异关卡后续重新做设计,先屏蔽」)——Boss/精英图全局池空→`_resolve_global_variant` 空行回落→region 布置原路兜底,玩家面零横幅零角标零资源偏斜;冒烟 7 处断言换代为「全局池空→region 兜底回落」路径(行数 7→6/boss 池去 global 行/L2 boss+elite 全局决议空行/格子级恰一行且在 {magma,ash}/快照空态一致/同种子确定性含 variants 字典逐位);resource_face 机制代码(RewardEconomy 偏斜投影/schema 互锁/负样本)零触碰,重建设计直接加行即可;删行依据与首发批裁决记录改写为屏蔽注记(git 历史可溯)。**②DS_DEBUG 写死**:`main.gd:219` 环境变量读取退役、`_debug_entry` 恒 true(用户令「直接写死,不读环境变量,太麻烦」)——全库 DS_DEBUG 引用清零(含浮窗内用户可见工具提示文案);闸断言三处换代(smoke V2f 改恒开/click_probe 原「未设时不得默认开」反转为「未恒开即失守」并挪至 boot 段、设置面关闭段补 ModalStack 遮挡恢复断言/tune_probe 删自设环境变量死代码);**发布前删除事项=main.gd:219 一行**(无 CI,两道恒开断言会红兜底)。**闸面三修**(首跑抓出):冒烟跨块引用 bb/crng 出作用域 Parse Error(hoist 为 boss1_ids/variants/calls);shot_probe 截图前走 `_close_modal` 真实单口归一浮窗(写死后 boot 即开压菜单——boot 顶层检查/golden 19%/文字占比 0.00% 三处受扰;视觉契约与调试模式解耦,发布前删写死时基线不受影响);click_probe 恒开断言归位。**基线 09 指纹刷新**:静态扫描红项清单与基线不符,经主区与分支 `audit_file_headers` 输出逐行一致(128 行)证明非本批引入(漂移源于 20f4a7dc 之后主区 .gd 头注变动),按「人工确认后重生成」条款对齐指纹,红项仍为存量 127 处。**验收**:全量闸通过(存量红零新增,回执 commit=HEAD);worktree 与分支已清理。**读数义务归 M4**:Boss 图资源面分布与 region 布置读数(本批 MC 零跑,内容回落既有 region 行为)。**反哺点待裁**:设计语义面五处描述已撤回功能(地形变异.md §全局作用域型/HUD 框架三层预告/教学引导手册行/难度曲线与Boss 两行/art-bible §4.6.3 括注);机械面四件已随批同步(07 ⑪ 转记失效/architecture 行数 7→6/数据配置清单/06 战斗节点地图类型差异化条)。 - **504(2026-10-16,需求卡片升 v2.0——三轮复查结论落文,U0 门只欠发令)**:grilling 两轮+一轮只读深挖后一次性升版(用户令「实现相关的都可以用你推荐的,但重新检查一遍」)。**首轮十一问按推荐全裁**:L2 承载+2 例(Core-Core/A1×A2 随属主批增量)/parity 收编为**条件闸件**(仅 diff 触 data-tables/meta-save/rng/settlement 跑,否则 SKIP,守住「MC 无数值改动零跑」红线)/CI timeout 前提已死、改铸为轻闸墙钟预算(T0-② 实测,超用户上限才议分层或并发)/integration 并入 run_all manifest/S9 存量豁免维持、本批新写测试件全类型标注(升 🔴 契约 8)/golden 失效显式重制+呈报+复闸/smoke 两名不改名只加注。**第二轮两项亦按甲**:N1=§2 改指针(事实面归 architecture.md「测试与质量域」三张表,禁双载体漂移——ADR 教训同款防复发);N2=T6 收 S3 路径修正(core→game/core+零命中自检,+0.25d)。**事实订正四处**:PASS 串判定面实为 8 件探针(非 4)/game/smoke 实为 29 个 .gd(非 38)/T5 的 status_container 不是零覆盖(2 单测已指名)→ 换 board_generator/README「ci-removal 批已改写门禁节」前提不成立(分支未合入)→ CI 残留 8 处(README 6+run_all.gd 头注 2)改由本批 T2 清除。**死引用订正**:CLAUDE.md:21/:106/:116 与 §0 的 :102-103(文件已压到 71 行)、tests.yml(已删)、golden_compare.py:11(实际 :13)。**新增义务**:本批落地后须回写 architecture.md 的「三条判定硬闸现行状态」矩阵(缺失→在位)与 S 表。估时 T0–T8≈6.0–7.5d(+0.5d),软盒 7d 维持。**U0 现状:零待裁点+v2.0 成立,仅欠 `/iter` 发令**;唯一待补输入=轻闸墙钟上限(T0-② 前给即可)。文档三闸全绿。 + +- **505(2026-10-16,调试入口修正批——503「写死」的用户实测否决与纠偏)**:用户 F5 实测 503 交付态:**浮窗 boot 自动开把开始屏盖成调试面板**(640×约700 居中面板盖掉大半个屏,用户体感「直接进到了调试面板,退出之后才回到开始面板」),且**菜单层没有任何浮窗入口**——iter18 卡⑦ P2 甲把右下角「调试」角标迁去两屏的理由正是「DS_DEBUG=1 启动即开」,该理由随写死后依然成立但用户根本不接受这个形态(从没带过环境变量、首次见)。用户令查三问(入口在哪/何时触发/触发后效果)后定性:我的「完成逻辑」把「写死」做成了「原样继承启动即开」,而用户要的是「调试常驻可用」——入口可见、按需开。修正(worktree `g-plan2-dbgentry-20261016`,合并 752c9a0a,主区冒烟 1127/0 兜底):①`main.gd` `_ready` 删 boot 自动开浮窗(`_debug_entry` 门控保留)②恢复开始屏右下角「调试」角标——实现自 git 历史 `40fb4a5c^` 原样恢复(PRESET_BOTTOM_RIGHT 16px 角距+FS_HINT+豁免宽度基准),`ui_assembler` 返回键补 `dbgbtn`、`scenes/menu_screen.gd` 暴露句柄、接线 `pressed→_on_debug_badge_pressed`③浮窗 desc 文案改三处入口说明。**探针/断言四处换代**:smoke V2f 改「boot 不开浮窗+菜单角标可见」、V1a 收回严格判据;click_probe boot 检查反转+角标检查翻转为缺失才红+删浮窗恢复断言;tune_probe ① 改角标真实点击开窗路径;shot_probe 删浮窗归一段(死代码)。**首轮冒烟抓两处自伤并修复**(教训在册):①返回键漏 `dbgbtn`→`menu_screen` 句柄 null→`_build_ui` 中断→V1c 层序红(_layer_node 缺失三层)——装配器加返回键必须同批查 `menu_screen.gd` 句柄赋值;②角标 `visible` 赋值在 `_build_ui`(读到 `_debug_entry` 初始 false)而门控落定在其后——**门控变量赋值晚于消费点的顺序坑**,挪到 `_ready` 门控行之后。**发布前删除事项更新**:删 `main.gd:219` 一行时,角标可见性(同函数下一行)随门控失效自动隐藏,无第二删除点。 + +- **506(2026-10-16,迭代 29 测试分层与门禁语义修正批总收口)**:用户 `/iter 29` 发令,单会话八提交交付(worktree `g-plan2-iter29-testlayers`,分支 `iter29-testlayers-20261016`)。**核心是把验收语义从「跑一遍没崩=通过」扭到「三层完备+判定可靠」**,三硬闸每条都有反例实证:改前「探针打印 PASS 后 quit(1)」判全绿、「零断言套件」判 all green (0 checks)、「子套件中途报 SCRIPT ERROR」判 PASS;改后三条全部见红。**本机两个坑写进注释防复发**:①`OS.execute` 的输出管道恒空(子套件输出须交命令解释器重定向落盘再读);②`OS.get_executable_path()` 返回 GUI 子系统 exe,其子进程 stdout/stderr 句柄无效,必须换 `*_console.exe`。断言下限用专用退出码 2(不是 1——致命类套件期望 1,返回 1 会被 expect 协议放过)。**三层承载**:L1 补六套(board_generator/minesweeper_rules/config_db_resolver/config_db_cross_validator/hud_presenter/battle_narrator,27→33 套),三屏壳归 L3 探针;L2 建 `tests/integration/` 首个用例(存档⇄RNG 往返,含旧档经迁移链后仍可续玩这条真实路径);L3 新建 `screen_flow_probe`(六步主流程+两条回退,每步判屏级互斥)入轻闸。**parity 收编为重闸 11 号条件闸件**(每次跑批做触面判定,触面也不自动跑——MC 须用户批准),本批即判触面,挂 NEED-APPROVAL 待裁。**扫描域**:S3 路径由裸 `core` 修正为 `game/core`(此前恒零命中恒绿、什么都不扫),补 S5,并给每条规则加零命中自检(扫描面为空即判红,路径写歪时自检跟着一起红)。**四处经用户裁决的改动**:fx_grant_gp 补行来源豁免(每局 7 次断言失败被闸长期漏过)/manifest 增 negative 字段(负面套件的错误是测试目标)/config_db_test 的 rows 定型数组赋值改 assign(**10 条敌人表行集纪律断言从未执行过**,修后两条正向见红=夹具缺 depth-0 锚行,补锚行全绿)/system_contracts 空桩换真实实例。**台账与时效**:15 个游离脚本逐个定性成表(存疑按在役);golden 先判再动,实测 changed 0.00%/0.15% 判有效不重制。**架构事实源回写**:三硬闸矩阵「缺失」→「在位」、S 表 S3/S5 更新、新增「三层承载与判定原则」小节。**验收**:重闸 exit=0(存量红零新增,回执 commit=HEAD),冒烟 1127/0 持平,轻闸墙钟 363→405s。**遗留待裁**:11 号闸 parity 是否批准发起、GDD 反哺三条、06 在册七项。 diff --git a/design/dev/architecture/architecture.md b/design/dev/architecture/architecture.md index 7af251b6..85d460ff 100644 --- a/design/dev/architecture/architecture.md +++ b/design/dev/architecture/architecture.md @@ -141,8 +141,8 @@ | 模块 | 载体 | 独占职责 | 对外暴露 | 判定口径 | |---|---|---|---|---| | **run_smoke**(系统级断言面) | `tools/run_smoke.py` | 冒烟总入口与判定权:headless 跑 `res://smoke/smoke_all.gd` 全量(轻闸首项,迭代 28 后在册 1125 项检查)+ 三闸判定 | 进程退出码(0=PASS / 1=FAIL) | 三闸齐备、判定无洞:①退出码 = 0;②摘要行「冒烟完成:N 项检查,0 项失败」在案且失败数为 0;③输出扫 `SCRIPT ERROR` / `Parse Error` 标记行。外部 wall-clock 超时在位(超时 = FAIL) | -| **run_all**(单元套件) | `game/tests/run_all.gd` | 27 套件单元测试统一入口:套件 manifest 逐条「套件 → 期望退出码」,致命类 expect=1 独立子进程实跑 | 逐套件结果 + 汇总退出码(任一套件失败 quit(1)) | 套件级退出码协议在位;**SCRIPT ERROR 扫描缺失**(脚本错误不改套件退出码时静默漏过);**断言数下限缺失**(套件零断言退出 0 照样绿) | -| **probe_gate**(场景探针) | `tools/probe_gate.sh` | headless 场景探针段编排:默认段 ui_input_probe / ui_struct_probe / ui_contrast_probe,`--click` 加跑 ui_click_probe,`--full` 加跑 ui_probe / ui_tune_probe / t17_defer_probe / fx_probe;孤儿 Godot 预清 + 批末残留清点(必须 0) | 段级 FAIL 输出 + 汇总退出码 | 每段 timeout 守卫在位(挂死 124/137 = FAIL,不静默);**多数探针不查进程退出码**(仅个别段以 grep PASS 标记兜底)——退出码判定缺失 | +| **run_all**(单元套件+L2 集成) | `game/tests/run_all.gd` | 33 套统一入口(32 单元/集成 + 见下):套件 manifest 逐条「套件 → 期望退出码」,致命类 expect=1 独立子进程实跑;`negative` 字段标负面套件(仅豁免错误扫描一闸) | 逐套件结果 + 汇总退出码(任一套件失败 quit(1)) | 套件级退出码协议在位;三硬闸齐备(入口内扫子套件输出 + 编排层扫 `unit.log`;`Kit.finish` 零断言返回专用码 2)。子进程输出须经系统命令解释器重定向落盘再读——`OS.execute` 的输出管道在本机恒空 | +| **probe_gate**(场景探针) | `tools/probe_gate.sh` | headless 场景探针段编排:默认段 ui_input_probe / ui_struct_probe / layout_probe / ui_contrast_probe / **screen_flow_probe**(L3 屏壳切换),`--click` 加跑 ui_click_probe,`--full` 加跑 ui_probe / ui_tune_probe / t17_defer_probe / fx_probe;孤儿 Godot 预清 + 批末残留清点(必须 0) | 段级 FAIL 输出 + 汇总退出码 | 每段 timeout 守卫在位(挂死 124/137 = FAIL,不静默);判定统一走 `verdict` 函数——退出码为唯一权威判据,PASS 串降级为附加判据,逐段跑 `tools/error_scan.py` | | **render_gate**(视觉 golden) | `tools/render_gate.sh` | 窗口化渲染闸:①shot_probe 七屏截图 → shot_checker A-D 语义断言;②fx_shot_probe / board_shot_probe 取证(超时/零字节/文件缺失任一 = FAIL);③棋盘 golden 逐像素容差比对(差 ≤16 / changed ≤2%,provisional)+ 菜单 golden;④批末孤儿残留清零 | 段级 FAIL 输出 + 汇总退出码 | 查退出码在位(每段 timeout 守卫 + 退出码/取证文件双检);**SCRIPT ERROR 扫描缺失**;断言数下限不适用(判定主体 = golden 容差与取证文件存在性,非断言计数) | | **static_scan**(静态扫描域) | `game/tests/static_scan.sh` | 静态扫描域 S1–S10 可执行载体(grep 规则 + `tools/audit_type_hints.py`〔S9〕+ `tools/audit_file_headers.py`〔S10〕) | fail 聚合退出码(任一硬失败规则命中即 exit 1) | 查退出码在位;SCRIPT ERROR 扫描与断言数下限不适用(逐规则命中即红,无运行时错误面与断言计数);S3 现为 warning 级、不参与 fail 判定(见下表) | @@ -150,9 +150,9 @@ | 硬闸 | run_smoke | run_all | probe_gate | render_gate | static_scan | |---|---|---|---|---|---| -| **退出码优先** | 已落实(闸①) | 已落实(套件 manifest expect 协议) | **缺失**(多数探针不查退出码) | 已落实(段级退出码 + 取证双检) | 已落实(fail 聚合退出) | -| **SCRIPT ERROR 扫描** | 已落实(闸③,外部扫描——GDScript 无脚本错误陷阱 API,闸设在外部包装) | **缺失** | **缺失** | **缺失** | 不适用(静态域无运行时错误面) | -| **断言数下限** | 无独立下限(闸②摘要行必须在案,防「跑一半仍 PASS」;脚本错误吞断言路径由闸③兜住) | **缺失**(27 套件跑完不校验断言总数,零断言套件可绿) | 不适用(逐段 PASS 标记判定,无断言总数概念) | 不适用(golden 容差 + 取证存在性判定) | 不适用(逐规则命中判定) | +| **退出码优先** | 已落实(闸①) | 已落实(套件 manifest expect 协议) | 已落实(`verdict` 函数逐段判退出码,PASS 串不作判据) | 已落实(段级退出码 + 取证双检) | 已落实(fail 聚合退出) | +| **SCRIPT ERROR 扫描** | 已落实(闸③,外部扫描——GDScript 无脚本错误陷阱 API,闸设在外部包装) | 已落实(入口内扫子套件输出;子进程的 stdout/stderr 须先落盘——`OS.execute` 管道在本机恒空) | 已落实(逐探针段 `tools/error_scan.py`,标记与 run_smoke 同源) | **缺失**(本批未收——判定主体是 golden 容差与取证存在性;登记 06) | 不适用(静态域无运行时错误面) | +| **断言数下限** | 无独立下限(闸②摘要行必须在案,防「跑一半仍 PASS」;脚本错误吞断言路径由闸③兜住) | 已落实(`Kit.finish` 零断言返回专用退出码 2——与「断言失败=1」区分,防致命类套件的 expect 协议放过) | 已落实(退出码 0 但无该探针签名标记亦判红——防静默退出) | 不适用(golden 容差 + 取证存在性判定) | 不适用(逐规则命中判定;另有扫描面为空即判红的零命中自检) | 背景事实(run_smoke 头注在案):GDScript 运行时脚本错误不改进程退出码、不计入冒烟失败数、且静默中断所在函数剩余断言——2026-09-19 状态记录 83 实证「309 项 0 失败」实为断言未跑全。这是 SCRIPT ERROR 扫描必须存在于判定层的原因。 @@ -164,15 +164,29 @@ |---|---|---| | S1 | 玩法代码禁 `randomize()`、自建 `RandomNumberGenerator`(D2 Rule 5) | **在位**(硬失败;扫描面 game/core + game/presentation) | | S2 | 禁全局随机函数 `randi()` / `randf()` / `randf_range()`——全局 RNG 绕开 RngService(specialist 补登) | **在位**(与 S1 同段硬失败;实例方法 `.randi()` 排除、`rng-seed-exempt` 豁免注记在案) | -| S3 | core/ 禁 ConfigDB 外 data 表加载(`load(` / `preload(` / `ResourceLoader.load`;schema class_name 直引豁免)(D2 Rule 2) | **未落地**(脚本内仅 warning 级占位、不参与 fail 判定;且扫描路径 `core/` 自 2026-09-24 布局迁移后不存在——实际为 game/core,现行实跑恒零命中) | +| S3 | core/ 禁 ConfigDB 外 data 表加载(`load(` / `preload(` / `ResourceLoader.load`;schema class_name 直引豁免)(D2 Rule 2) | **在位**(warning 级——class_name 直引误报需人工复核;路径已由裸 `core` 修正为 `game/core`,此前恒零命中静默空转;豁免 config_db.gd 自身=数值单一事实源的正主) | | S4 | core/ 禁 `extends Control/Node2D`、禁 `.tscn` preload(D2 Rule 7) | **在位**(硬失败) | -| S5 | core/ 禁裸引用 autoload 全局标识符(`ConfigDB` / `RngService` 直接写 = --script 模式 parse error)——构造注入 only(specialist 升格) | **未落地**(脚本 TODO 注记在案:grep 近似误报多,需 AST 级或精确白名单;已有真实反面案例) | +| S5 | core/ 禁裸引用 autoload 全局标识符(`ConfigDB` / `RngService` 直接写 = --script 模式 parse error)——构造注入 only(specialist 升格) | **在位**(硬失败;形态=标识符后紧跟 `.` 的成员访问,排除注释行与字符串字面量) | | S6 | core↔core 信号连接只许方法 Callable(禁 lambda 捕获 self);发射者禁持有订阅者反向引用 | **未落地**(脚本 TODO 注记在案) | | S7 | B1 编排唯一性:B2/B3/B4 API 符号命中文件 ⊆ B1 文件集;A2/B3/B4 零相互直调(B1 AC-43) | **未落地**(脚本 TODO 注记在案) | | S8 | 结算路径 `await` 零命中(B1 结算/编排文件集);字面量扫描 = warning 级 + 人工三要素复核(D2 AC-14 口径) | **未落地**(脚本 TODO 注记在案) | 域外扩充两件(均硬失败在位):**S9** 类型闸(`tools/audit_type_hints.py`,迭代 27 T5——产品码无类型容器/有值返回缺类型/参数缺类型);**S10** 文件头闸(`tools/audit_file_headers.py`——头注 ≤8 行 + 日期/变更叙事黑名单)。 +### 三层承载与判定原则 + +测试分三层,各有在册承载与入闸方式(细目见 `game/tests/README.md`): + +| 层 | 判什么 | 承载 | 入闸 | +|---|---|---|---| +| L1 单元 | 最单元的逻辑代码对不对 | `game/tests/unit/` | `run_all.gd`(闸 02) | +| L2 集成 | 跨系统闭环对不对 | `game/tests/integration/` | 同上(同一入口同一协议) | +| L3 系统/场景 | 跑起来之后 UI 与场景切换对不对 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04) | + +**判定原则**:验收不能只是「跑一遍没崩」。三硬闸(退出码/脚本错误扫描/断言数下限)是判定的底线, +缺一即判定有洞;「日志里写着 PASS」不作判据。另有两类静默失效必须机制性防范——规则扫描面为空 +(路径写错即恒零命中恒绿)与子进程输出丢失(本机 `OS.execute` 管道恒空),两者都以显式自检拦住。 + ### 依赖约束 测试域禁止反向依赖呈现层具体实现。五闸及其调用的探针场景、审计脚本只允许依赖两类边界: diff --git a/design/gdd/06-待办清单.md b/design/gdd/06-待办清单.md index eca9b539..4f07e43d 100644 --- a/design/gdd/06-待办清单.md +++ b/design/gdd/06-待办清单.md @@ -696,3 +696,21 @@ --- + +#### 测试域遗留(2026-10-16 迭代 29 测试分层与门禁语义修正批登记) + +**现状**:迭代 29 把验收语义从「跑一遍没崩=通过」扭到「三层完备+判定可靠」,三硬闸(退出码/脚本错误扫描/断言数下限)已落地,L2/L3 从零建起承载。本批未收的余量集中登记在此,均不阻塞、不排期。 + +| # | 事项 | 归属与要点 | +|---|---|---| +| 1 | **余下 3 个 core 模块仍零指名单测**:`board_data`/`reward_economy`/`skill_lottery` | 测试基建批。本批补了 minesweeper_rules/board_generator/config_db_resolver/config_db_cross_validator 四件;余三件按同一手法补(判据=能否在 `--script` 模式不加载 autoload 验完) | +| 2 | **render_gate 的 SCRIPT ERROR 扫描仍缺失** | 测试基建批。判定主体是 golden 容差与取证文件存在性,脚本错误不改退出码时无扫描面;本批只把矩阵状态如实记为「缺失」,收它的写法待裁 | +| 3 | **静态扫描域 S7/S8 仍未落地** | 工程债批。S7=B1 编排唯一性(B2/B3/B4 API 符号命中文件 ⊆ B1 文件集);S8=结算路径 `await` 零命中。本批补的是 S3 路径与 S5,S6 亦仍在 TODO | +| 4 | **E 级卫生件**(test_kit static var 串台风险、4 套件自建断言出口、157 处 untyped 容器、76 处 lambda) | 工程债批。本批只统一了**新写**断言的出口,存量未动;test_kit 的 static var 是「并发跑批」的前置障碍(并发分层方案的前提) | +| 5 | **行覆盖率度量缺失** | 基建评估项。Godot 无内建、第三方属新基建;现行补洞靠「零指名清单 + 人工判定」,是否引入度量待裁 | +| 6 | **`smoke_all.gd` 拆分**(单文件承载 1127 项检查) | 工程债批。本批按契约只做入闸与改名裁点,不拆不重构;拆分登记在册 | +| 7 | **parity 逐位一致比对的触面判定为粗粒度路径匹配** | 测试基建批。触面表含 `battle_orchestrator.gd`,改该文件即要求批准发起 MC;若嫌过重可精化到「是否改动取数/结算数值」的语义面 | + +**归属**:测试基建批/工程债批(不阻塞,不排期) + +**状态**:**在册待裁**(七条均为本批未收的余量;迭代 29 已交付的部分见实现报告与 architecture.md 测试与质量域节) diff --git a/design/qa/iter29-实现报告-2026-10-16.md b/design/qa/iter29-实现报告-2026-10-16.md new file mode 100644 index 00000000..408c736c --- /dev/null +++ b/design/qa/iter29-实现报告-2026-10-16.md @@ -0,0 +1,167 @@ +# 迭代 29 实现报告 · 测试分层与门禁语义修正批(2026-10-16) + +- 需求卡片:`design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md`(**v2.0**,三轮复查定稿、零待裁点) +- 开工门 U0:用户 `/iter 29` 发令(2026-10-16)。基线 master `752c9a0a` +- worktree:`g-plan2-iter29-testlayers`,分支 `iter29-testlayers-20261016`,八提交(`698d864d` … `c3b64030`) +- 用户原始诉求(本批要兑现的三条):①最单元的逻辑代码有它的单元测试;②整体的模块有它的接口测试; + ③整体的系统在执行之后,也有它 UI 方面的和场景切换相关的测试。**并且验收不能只是"冒烟"—— + 可以是冒烟,但更重要的是逻辑正确性** + +--- + +## 一、批次概览 + +| 卡 | 内容 | 状态 | +|---|---|---| +| T0 | 证据坐实与基线冻结(三反例实测 + 墙钟 + 行号复核 + 存量红留档) | ✅ | +| T1 | 本地闸判定三硬闸(退出码/脚本错误扫描/断言数下限) | ✅ | +| T2 | 术语订正 + CI 残留清除(机械面) | ✅ | +| T3 | L2 接口层建承载(`integration/` + parity 条件闸件收编) | ✅ | +| T4 | L3 屏壳切换探针 `screen_flow_probe`(入轻闸默认段) | ✅ | +| T5 | L1 覆盖补洞(4 个 core + 呈现层两件) | ✅ | +| T6 | 扫描域:S3 路径修正 + 零命中自检 + 补 S5 | ✅ | +| T7 | 游离脚本台账 + golden 时效判定 | ✅ | +| T8 | 文档同步闸(架构事实源回写 + 06 登记)与全闸 | ✅ | + +**读数**:冒烟 **1127/0**(与开工日持平,本批零新增冒烟断言);单元套件 **27 → 33 套**; +在闸探针 **9 → 10 件**(新增 `screen_flow_probe`);轻闸 `--quick` 墙钟 363s → 405s; +全量重闸 **exit=0,存量红零新增**。 + +--- + +## 二、四件事的交付 + +### ① 本地闸三硬闸(本批改造成效最大的一卡) + +判定语义从「超时 + 日志含 PASS 串」扭到三硬闸,**每一条都有反例实证**(不是推断,是跑出来的): + +| 硬闸 | 反例构造 | 改前判定 | 改后判定 | +|---|---|---|---| +| 退出码为唯一权威判据 | 探针打印 PASS 后 `quit(1)` | `[probe_gate] 全绿` ❌ | `FAIL — 退出码=1(PASS 串不抵)` ✅ | +| 脚本错误扫描命中即红 | 套件中途触发 `SCRIPT ERROR` 后仍退出 0 | `[run_all] PASS` ❌ | `FAIL — 输出含脚本错误标记` ✅ | +| 断言数为零即红 | `Kit.finish()` 时 `checks==0` | `all green (0 checks)` ❌ | 返回专用退出码 **2** → `FAIL` ✅ | + +实现要点: +- `probe_gate.sh` 八件探针判定统一走 `verdict` 函数(退出码优先 → 脚本错误扫描 → 签名标记附加判据); + `ui_click_probe` 本就走退出码语义、日志不写 PASS,签名标记传它自己的 `click_probe` 串,不强加。 +- 新闸件 `tools/error_scan.py`:错误标记 `import` 自 `run_smoke.py` 的 `ERROR_MARKERS`,**禁第二份清单**。 +- `run_all.gd` 侧遇到两个本机事实(都写进注释防复发): + ① `OS.execute` 的输出管道在本机恒空(实测 0 行)——子套件输出必须交给系统命令解释器重定向落盘再读; + ② `OS.get_executable_path()` 返回的是 GUI 子系统 exe,它起的子进程 stdout/stderr 句柄无效—— + 必须换 `*_console.exe`(`GODOT_BIN` 优先)。 +- 断言下限用**专用退出码 2**(不是 1):致命类套件 manifest 期望 1,零断言若也返回 1 会被 expect 协议放过。 + +### ② L2 接口层从零建承载 + +- 建 `game/tests/integration/`,首个集成用例 `save_rng_roundtrip_test.gd`(17 条断言):存档 ⇄ RNG 快照往返, + 跨 SaveManager(落盘 + 版本迁移链)× AdventureRunner(断点消费)× RngService(续跑零跳号)× 停点态确定性。 + 与 `unit/core/breakpoint_resume_test` 的分工:那件是单测只验首战开板一致,本件验跨系统闭环, + 含**「旧版本档经迁移链后断点仍可续玩」**这条真实玩家路径。 +- `tools/parity_anchor.py` 收编为重闸 **11 号条件闸件**:每次跑批都对 merge-base 做一遍触面判定 + (`data/tables`/`rng_service`/`save_manager`/`meta_progression`/`settlement`/`damage_math`/ + `damage_controller`/`battle_orchestrator`/`adventure_monte_carlo`),未触面 SKIP;**触面也不自动跑**—— + 它调的是 MC 脚本,属「跑批发起前须用户批准」红线,闸只负责把触面判定做一遍并显式要求批准 + (批准后 `PARITY_APPROVED=1` 重跑)。**本批即判为触面(改了 `battle_orchestrator.gd`),故挂在 NEED-APPROVAL 待你批准**(见第六节)。 + +### ③ L3 屏壳切换探针 + +新建 `game/smoke/screen_flow_probe.gd/.tscn`:走 菜单→营地→菜单→冒险→(关卡屏⇄战斗屏)→放弃→终局→菜单 +六步主流程与两条回退路径,每步断言屏级互斥 + 流程状态位,入**轻闸默认段**。全库 `change_scene` 零命中、 +屏切换全靠 visible 互斥,这正是最容易被装配顺序改动悄悄破坏又最难被单测覆盖的面。 + +判据两处订正(首跑实测抓出):① 屏标记不能裸看 `visible`——战斗容器 `battle_root` 在局外也 visible, +会把 boot 态误判成「菜单与冒险同时在场」;② 互斥不能用「可见屏计数 ≤1」——终局覆盖层合法叠在冒险内容之上 +(终局态 `is_adventure` 尚未收束,是设计如此),改为冲突对判定,合法叠加仅「终局+冒险」这一对。 + +### ④ L1 补洞 + 术语订正 + 台账清理 + +- 新增六套(全部进 manifest):`board_generator`(9 条)/`minesweeper_rules`(12 条)/ + `config_db_resolver`(8 条)/`config_db_cross_validator`(4 条)/`hud_presenter`(5 条)/`battle_narrator`(9 条)。 + 三屏壳的断言面由 ③ 的探针承担,不另起第二载体。 +- 术语:「冒烟」回归本义=秒级存活检查,写进 README 术语小节,并显式点破两处历史遗留命名 + (`run_smoke.py` 与 `game/smoke/` 名带 smoke,实际承载的是系统级断言面与场景探针——判定看语义不看文件名)。 +- 台账:`game/smoke/` 15 个不带 `.tscn`、不入闸的手动入口件逐个定性成表(用途 + 触发方式 + 结果由谁判), + 存疑一律按在役登记并标注,禁「没看见=死件」式清理。 +- golden:**先判再动**——实测棋盘三图 changed 0.00%/0.00%/0.00%、菜单 0.15%,均远低于 2% 阈值 ⇒ 判有效, + 留在位不重制(禁「重制一下就绿了」掩盖真实视觉回归)。 + +--- + +## 三、代拍板追认单(超清单必呈报;本批共 12 项) + +| # | 代拍板事项 | 处置与依据 | +|---|---|---| +| 1 | 三硬闸的具体实现写法 | `probe_gate` 抽 `verdict` 函数;错误扫描抽独立闸件 `tools/error_scan.py`(标记 import 自 `run_smoke.py`) | +| 2 | 断言数下限的阈值 | 每套件 ≥1(防误伤小套件);零断言用专用退出码 2 与「断言失败=1」区分 | +| 3 | `integration/` 内部目录分类 | 平铺,按系统命名(`save_rng_roundtrip_test.gd`)——首例而已,够用不加层 | +| 4 | 新集成套并入 `run_all` manifest 的执行细节 | 零新机制,与单元套件同一入口同一协议 | +| 5 | 新单测/新探针的文件命名与落点 | 沿用 `[system]_[feature]_test.gd` 与 `presentation/` 归位 | +| 6 | 4 个 core 模块单测的断言面取舍 | 各 4–12 条真值断言,期望值一律由盘上/注入内容数出来(零玩法数值字面量) | +| 7 | **断言本身写错且与现行设计冲突**时的修法(契约 1 唯一例外) | 见第四节「经你裁决的四项」,逐条列明 | +| 8 | 游离脚本「在役/失效」的判定 | 存疑按在役登记并标注;`CLAUDE.md` 在册入口禁当死件 | +| 9 | S5 规则的正反例构造 | 形态=标识符后紧跟 `.` 的成员访问,排除注释行与字符串字面量;core 实测零命中 | +| 10 | 提交切分与分支命名 | 八提交按 T0–T8 一卡一笔;分支 `iter29-testlayers-20261016` | +| 11 | **S3 零命中自检的实现形态** | 扫描路径收为单点变量 + 规则自己数扫描面文件数,为零即判红,且规则体包进自检成立分支 | +| 12 | **条件闸件的 diff 触面路径表** | 写进 `run_all_gates.sh` 内(不自造第二份清单),见上文 ② | + +--- + +## 四、经你裁决的四项(施工中冒出的分歧,未自答) + +1. **`fx_grant_gp` 补进行来源断言豁免表**(你裁「本批补豁免」):`grant_segment.gd:221` 发「专长·GP 入账」 + 效果行(source_class=`fx_grant_gp`、origin_cell=(-1,-1)),而 `SOURCE_EXEMPT_CLASSES` 只豁免了 `fx_reveal` + ⇒ 每局触发行来源断言失败 7 次,因脚本错误不改退出码被闸长期漏过。改动=一行常量加一个字符串, + 零玩法行为变更(迭代 21 同款修过 `fx_reveal`,本例是同一张表的漏网)。 +2. **manifest 增 `negative` 字段**(负面套件豁免错误扫描一闸):仅 `reward_dangling_treasure` 一件—— + 坏表是它的测试目标,输出里的 Parse Error 属误伤。退出码与断言下限照判,不是开后门。 +3. **`config_db_test` 的 10 条被吞断言**(你裁「本批全修」):`rows` 是 `Array[Dictionary]` 定型属性, + 直接赋 untyped 数组字面量触发 `Invalid assignment` ⇒ **中断整个函数**,敌人表「行集构成三条纪律」 + 的 10 条断言从未执行过(文件 31 处 check,实跑 21 条)。改 `rows.assign([...])` 后 26 条全跑, + 其中两条正向断言见红,根因是**夹具缺 depth-0 锚行**(`validate` 的 anchor 规则),补锚行后全绿。 +4. **`system_contracts_test` 的空桩换真实实例**(同上):`RngStub`/`UnitContainerStub` 赋给定型成员 + 报 `Trying to assign value of type ''`,`RngStub` 类删除,`UnitContainer`/`StatusContainer` 改真实实例。 + +--- + +## 五、闸与读数(全量重闸 `tools/run_all_gates.sh`,commit `c3b64030`) + +| 闸 | exit | 说明 | +|---|---|---| +| 01 冒烟全量 | 0 | **1127 项检查 0 项失败**(与开工日持平) | +| 02 单元套件 | 0 | **33 套**(26 单元 + 1 集成 + 6 新单测);编排层另扫 `unit.log` 零命中 | +| 03 探针 --click | 0 | 五件(含新增 screen_flow)全绿,残留 Godot=0 | +| 04 探针 --full | 0 | 四件全绿 | +| 05 渲染闸 | 0 | shot_checker 全过;棋盘 golden 三图 0.00%;菜单 0.15%(≤2%) | +| 06 gdd_lint / 07 doc_link / 08 doc_term | 0 | doc_term 首跑抓到我新写的退役词「对拍」1 处,已改正 | +| 08b active.md 体量 | 0 | — | +| 09 静态扫描 | 1 | **存量红零新增**:S10 文件头存量 127 处,指纹与基线 `4354b2aa72a7` 一致 | +| 10 熵增闸 | 0 | — | +| 11 回放确定性对拍 | NEED-APPROVAL | 本批 diff 触及结算面,对拍需你批准发起(见第六节) | + +**验收通过**(`all_green=true`,存量红零新增)。轻闸 `--quick` 墙钟 405s(开工日 363s + 新探针约 40s)。 + +--- + +## 六、待裁与遗留 + +0. **【合回状态:本批暂不合回 master,等两个非本批红处理完】** 同步 master(`60449941`)后复跑重闸冒出两个红, + 已取证均与本批零关系:①**渲染闸** `menu_boot.png` changed=4.61%(超 2%容差)——在主区(不含本批任何改动) + 跑同一道闸,结果一模一样 4.61%,棋盘三张 golden 仍 0.00%;②**gdd_lint** `01-核心循环.md` R5 + 「一场战斗的流程」节无流程图——master 提交 `272cc1ad` 的说明已自陈为并行会话 `9846a29f` 的既有问题。 + 按「禁带红合并」纪律,2026-10-16 用户裁「不合回,等你处理」:本批九提交留在分支 `iter29-testlayers-20261016`, + 待这两条在 master 上处理完(菜单 golden 需核热区后按 Q6 口径显式重制;01-核心循环 R5 归其引入批补流程图), + 我重跑重闸后立即合回。**不代为刷新基线**——那样会把 4.61% 的真实视觉差异固化成存量,热区原因不再有人追。 + +1. **11 号闸的 parity 对拍要不要跑一次**(需你批准,我不能自行发起):触面判定是机械的路径匹配, + 本批对 `battle_orchestrator.gd` 的改动实际只是加一个断言豁免常量(零玩法行为变更、零数值改动)。 + - 若按「MC 无数值改动零跑」红线 ⇒ 不跑,闸维持 NEED-APPROVAL(回执里留名,不判失败); + - 若要验证一次 ⇒ 批准后 `PARITY_APPROVED=1 tools/run_all_gates.sh`(三变体约 10–15 分钟)。 +2. **GDD 反哺点三条**(设计权威文档不随批改,待你裁决后另批落文): + - `design/dev/code-conventions.md` 测试纪律节:是否把「三硬闸」与「新写测试件全类型标注」写成硬纪律; + - `design/dev/test-conventions.md`:是否补「判定可靠性优先于覆盖率」一节(本批的核心命题); + - `design/gdd/07-体验验证清单.md`:`screen_flow_probe` 已转机检,是否把对应的人工走查项销号。 +3. **06 已登记本批未收的七项**(不阻塞不排期):余下 3 个 core 零指名模块、`render_gate` 的脚本错误扫描缺失、 + S7/S8(S6 亦仍在 TODO)、E 级卫生件、行覆盖率度量、`smoke_all` 拆分、parity 触面判定偏粗。 +4. **本批最有价值的副产品**(建议转设计侧裁决是否立规):脚本错误会**静默中断所在函数的剩余断言**而 + 套件仍报「全绿」——本批至少三处实证(敌人表行集纪律 10 条零覆盖、`Callable` 形参类型写错让解析函数 + 返回 null)。已由三硬闸之二机制性拦住,但是否把它写成 code-conventions 的硬纪律待裁。 diff --git a/design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md b/design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md index 834c1cc4..34c78718 100644 --- a/design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md +++ b/design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md @@ -62,13 +62,27 @@ ### 2.1 开工日实测读数区(T0-③ 填;本卡不预估值) +> **2026-10-16 开工日实采**(master `752c9a0a`——开工当日调试入口批刚合入,冒烟由 1126 升 1127;本批 worktree 与该提交同点实测,主区与 worktree 读数一致)。 + | 项 | 开工日 master 读数 | 出处 | |---|---|---| -| 冒烟项数/失败数 | T0 填(参照:iter28 收口 **1125/0**) | `tools/run_smoke.py` | -| `run_all` 套件数 | T0 填(参照:**27** 套) | `run_all.gd` manifest | -| 在闸探针数 | T0 填(参照:轻闸 4+`--click` 1+`--full` 4;`game/smoke/*.tscn` 共 12) | `tools/probe_gate.sh` | -| 轻闸/重闸墙钟 | T0-② 填(参照:`--full` 四件实测 17.4/49.6/63.0/21.9s) | `tools/run_all_gates.sh` | -| 各闸存量红清单 | T0 填(参照:`baseline.json` 十一闸现全绿) | `game/tests/.gates/baseline.json` | +| 冒烟项数/失败数 | **1127 / 0** | `tools/run_smoke.py`(`game/tests/.gates/smoke.log`) | +| `run_all` 套件数 | **27** 套(全绿) | `run_all.gd` manifest | +| 在闸探针数 | **9**(轻闸默认 4 + `--click` 1 + `--full` 4);`game/smoke/*.tscn` 共 **12** | `tools/probe_gate.sh` | +| 轻闸/重闸墙钟 | **run_all 全套 34s / smoke_all 71s / 轻闸 `--quick` 363s**(重闸待 T8 实测) | `tools/run_all_gates.sh` | +| 各闸存量红清单 | **01/02/03/04/05/06/07/08/08b/10 全绿;09 静态扫描 exit=1(S10 文件头存量 127 处,指纹 `4354b2aa72a7`)** | `game/tests/.gates/baseline.json` | + +### 2.1b T0-① 反例实测留档(缺陷坐实证据,2026-10-16) + +三个靶子在 **未修的现行闸** 上实跑,全部**判绿**=三硬闸缺陷坐实(不是推断,是跑出来的): + +| 靶子 | 构造 | 现行闸判定 | 结论 | +|---|---|---|---| +| a · 零断言套件 | `Kit.finish()` 时 `checks==0` → `quit(0)` | `run_all` 输出 `[run_all] PASS …(exit=0)`,汇总 `all green (29 suites)` | **断言数下限缺失**坐实(V3) | +| b · 脚本错误套件 | 断言中途触发 `SCRIPT ERROR: Invalid call. Nonexistent function 'nonexistent_method' in base 'Nil'` 后继续退出 0 | 同上 → PASS | **SCRIPT ERROR 扫描缺失**坐实(V2);该错误类型**未**中断后续断言(`all green (2 checks)`),但退出码与汇总仍全绿——只有外部扫描能抓 | +| c · PASS 后 `quit(1)` 探针 | 临时令 `ui_input_probe` 打印 PASS 后 `get_tree().quit(1)` | `probe_gate.sh` 输出 `[probe_gate] 全绿` | **退出码优先缺失**坐实(V1) | + +> 三件靶子均为临时注入,验证后已删除/`git checkout` 还原(`game/tests/run_all.gd`、`smoke/ui_input_probe.gd` 回到 HEAD 态)。T1 落地后须用同三靶子复跑,判红才算修好。 ### 2.2 本批专属增量事实(架构文档未载;A 类直读,2026-10-16 复核) diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 3a4397a6..ec79d000 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -21,8 +21,10 @@ signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态 const SOURCE_EXEMPT_TYPES := ["decay", "verdict"] # 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格; # fx_reveal=专长揭示掷格(落空行无真实格〔迭代 21 存量红修复——a57489e9 落地件三处空来源 -# 触发行来源断言,51 行/冒烟,两轮确定性复现〕) -const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal"] +# 触发行来源断言,51 行/冒烟,两轮确定性复现〕); +# fx_grant_gp=专长·GP 入账(钱包面而非棋盘面,行无格子可指——与 fx_reveal 同款的漏网豁免, +# 未豁免时每局触发行来源断言失败 7 次,因脚本错误不改退出码而被闸长期漏过) +const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal", "fx_grant_gp"] var db: ConfigDb var rng: RngServiceCore diff --git a/game/smoke/screen_flow_probe.gd b/game/smoke/screen_flow_probe.gd new file mode 100644 index 00000000..b633a9c0 --- /dev/null +++ b/game/smoke/screen_flow_probe.gd @@ -0,0 +1,186 @@ +# screen_flow_probe.gd — L3 屏壳切换探针(迭代 29 T4) +# 判的是「跑起来之后,屏与场景的切换对不对」:菜单 ⇄ 营地 ⇄ 冒险 ⇄ 终局 的主流程与回退路径, +# 每一步断言 **屏级互斥**(同一时刻最多一个屏可见)+ 流程状态位落到正确值。 +# 与 ui_click_probe 的分工:那件走的是「点某个按钮有没有反应」的行为走查(含战斗推进与结算数值面); +# 本件只走屏壳与流程状态,不推进战斗、不碰结算数值——屏级互斥是最容易被装配顺序改动悄悄破坏 +# 又最难被单测覆盖的面(全库 change_scene 零命中,屏切换全靠 visible 互斥)。 +# 运行:Godot_console.exe --headless --path . res://smoke/screen_flow_probe.tscn +extends Node + +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") + +var main: Node +var _problems: Array[String] = [] + + +func _ready() -> void: + await get_tree().process_frame + main = load("res://presentation/main.tscn").instantiate() + add_child(main) + await get_tree().process_frame + await get_tree().process_frame + + # 隔离存档(全 worktree 共享同一份 user://save.json——探针必须换注,禁碰真实档) + ProbeUiUtil.isolate_save(main, "smoke_screen_flow", false, false, false, false) + await get_tree().process_frame + + await _step_boot_menu() + await _step_menu_to_camp() + await _step_camp_back_to_menu() + await _step_menu_to_adventure() + await _step_level_to_battle_and_back() + await _step_abandon_to_end() + await _step_end_back_to_menu() + + if _problems.is_empty(): + print("[screen_flow_probe] PASS — 六步主流程+回退路径全绿(屏级互斥:任意时刻 ≤1 屏可见)") + get_tree().quit(0) + return + print("[screen_flow_probe] FAIL — %d 处问题:" % _problems.size()) + for p in _problems: + print("[screen_flow_probe] - " + p) + get_tree().quit(1) + + +# ---- 屏级互斥判据 ---- +# 屏标记取法:菜单/营地/终局看各自主内容层的 visible;冒险取流程状态位 run_scope.is_adventure +#(战斗容器 battle_root 在局外也 visible——裸看 visible 会把 boot 态误判成「菜单与冒险同时在场」)。 +# **合法叠加只有一对**:终局覆盖层盖在冒险内容之上(终局态 is_adventure 尚未收束,是设计如此)。 +# 其余任意两屏同时为真=互斥失守。 +func _screen_flags() -> Dictionary: + return { + "菜单": main._start_overlay != null and main._start_overlay.visible, + "营地": main.camp_overlay != null and main.camp_overlay.visible, + "冒险": main.run_scope != null and main.run_scope.is_adventure, + "终局": main._end_overlay != null and main._end_overlay.visible, + } + + +func _flags_text(f: Dictionary) -> String: + var on: Array[String] = [] + for k in ["菜单", "营地", "冒险", "终局"]: + if bool(f[k]): + on.append(k) + return "/".join(on) if not on.is_empty() else "无" + + +# ---- 流程状态位(与可见性是两回事,逐步单独判)---- +func _expect_adventure_flag(step: String, expect: bool) -> void: + var got: bool = main.run_scope != null and main.run_scope.is_adventure + if got != expect: + _problems.append("%s:流程状态位 is_adventure 应为 %s,实测 %s" % [step, str(expect), str(got)]) + + +func _expect_exclusive(step: String, expect: String) -> void: + var f: Dictionary = _screen_flags() + var pairs := [["菜单", "营地"], ["菜单", "冒险"], ["营地", "冒险"], ["终局", "营地"], ["终局", "菜单"]] + for pair in pairs: + var a: String = pair[0] + var b: String = pair[1] + if bool(f[a]) and bool(f[b]): + _problems.append("%s:屏级互斥失守(%s 与 %s 同时在场)" % [step, a, b]) + if not bool(f[expect]): + _problems.append("%s:期望屏「%s」不在场(实测 %s)" % [step, expect, _flags_text(f)]) + return + for k in ["菜单", "营地", "冒险", "终局"]: + if k == expect: + continue + if k == "冒险" and expect == "终局": + continue # 合法叠加:终局覆盖在冒险内容之上 + if bool(f[k]): + _problems.append("%s:屏「%s」不该在场(实测 %s)" % [step, k, _flags_text(f)]) + + +func _frames(n: int = 2) -> void: + for _i in n: + await get_tree().process_frame + + +# ---- ① boot:菜单独占 ---- +func _step_boot_menu() -> void: + _expect_exclusive("①boot", "菜单") + _expect_adventure_flag("①boot", false) + if main.debug_overlay != null and main.debug_overlay.visible: + _problems.append("①boot:调试浮窗 boot 自动开(菜单优先失守)") + + +# ---- ② 菜单 → 营地(真实按钮信号路径)---- +func _step_menu_to_camp() -> void: + var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix") + if campbtn == null: + _problems.append("②菜单→营地:营地按钮缺失") + return + campbtn.pressed.emit() + await _frames() + _expect_exclusive("②菜单→营地", "营地") + + +# ---- ③ 营地 → 菜单(回退路径:营地关闭发自覆盖层自身信号)---- +func _step_camp_back_to_menu() -> void: + if main.camp_overlay == null: + _problems.append("③营地→菜单:营地覆盖层句柄缺失") + return + main.camp_overlay.closed.emit() + await _frames() + _expect_exclusive("③营地→菜单(回退)", "菜单") + + +# ---- ④ 菜单 → 冒险(开新冒险 → 入口停点落关卡地图屏)---- +func _step_menu_to_adventure() -> void: + var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains") + if lvbtn == null: + lvbtn = ProbeUiUtil.find_button(main._start_overlay, "冒险", "contains") + if lvbtn == null: + _problems.append("④菜单→冒险:开始冒险按钮缺失") + return + lvbtn.pressed.emit() + await _frames(3) + _expect_exclusive("④菜单→冒险", "冒险") + _expect_adventure_flag("④菜单→冒险", true) + if main.run_scope != null and main.run_scope.adventure == null: + _problems.append("④菜单→冒险:冒险实例未建立(入口停点未达)") + if main.level_screen != null and not main.level_screen.visible: + _problems.append("④菜单→冒险:入口停点未落在关卡地图屏(level_screen 不可见)") + + +# ---- ⑤ 冒险内屏切换:关卡地图屏 ⇄ 战斗屏(屏内两态,屏级仍独占)---- +func _step_level_to_battle_and_back() -> void: + if not main.level_screen.visible: + _problems.append("⑤冒险内切换:前置不在关卡地图屏") + return + main._exit_level_screen() + await _frames() + if main.level_screen.visible: + _problems.append("⑤冒险内切换:退出关卡屏后 level_screen 仍可见") + if main._battle_root != null and not main._battle_root.visible: + _problems.append("⑤冒险内切换:战斗屏未接手(battle_root 不可见)") + _expect_exclusive("⑤冒险内切换(关卡→战斗)", "冒险") + main._enter_level_screen() + await _frames() + if not main.level_screen.visible: + _problems.append("⑤冒险内切换:回到关卡地图屏失败") + _expect_exclusive("⑤冒险内切换(战斗→关卡)", "冒险") + + +# ---- ⑥ 放弃 → 终局(放弃路径=回退路径的非正常出口)---- +func _step_abandon_to_end() -> void: + if main.run_scope == null or main.run_scope.adventure == null: + _problems.append("⑥放弃→终局:前置无冒险实例") + return + main._on_abandon_requested() + await _frames(4) + _expect_exclusive("⑥放弃→终局", "终局") + if main._end_overlay != null and not main._end_overlay.visible: + _problems.append("⑥放弃→终局:终局覆盖层未开") + + +# ---- ⑦ 终局 → 菜单(终局出口返回开始界面)---- +func _step_end_back_to_menu() -> void: + if main._end_overlay == null or not main._end_overlay.visible: + _problems.append("⑦终局→菜单:前置不在终局") + return + main._end_overlay.continue_pressed.emit() + await _frames(3) + _expect_exclusive("⑦终局→菜单", "菜单") + if main.run_scope != null and main.run_scope.is_adventure: + _problems.append("⑦终局→菜单:冒险态未收束(run_scope 仍标 is_adventure)") diff --git a/game/smoke/screen_flow_probe.tscn b/game/smoke/screen_flow_probe.tscn new file mode 100644 index 00000000..5d847fb3 --- /dev/null +++ b/game/smoke/screen_flow_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://smoke/screen_flow_probe.gd" id="1"] + +[node name="ScreenFlowProbe" type="Node"] +script = ExtResource("1") diff --git a/game/tests/.gates/baseline.json b/game/tests/.gates/baseline.json index d010677a..459db1cd 100644 --- a/game/tests/.gates/baseline.json +++ b/game/tests/.gates/baseline.json @@ -44,6 +44,10 @@ "10 熵增闸": { "exit": "0", "fp": "green" + }, + "11 回放确定性对拍": { + "exit": "SKIP", + "fp": "skip" } } -} \ No newline at end of file +} diff --git a/game/tests/README.md b/game/tests/README.md index c959ad58..8e63de73 100644 --- a/game/tests/README.md +++ b/game/tests/README.md @@ -1,8 +1,8 @@ # Test Infrastructure — Dungeon Sweeper **Engine**: Godot 4.7.2-stable(GDScript 单语言) -**Test Framework**: 双载体——`--headless --script` 逻辑冒烟 + headless 场景探针;gdUnit4 **deferred**(D2 OQ1 关闭形态,正式 CI 期再议) -**CI**: `.github/workflows/tests.yml`(首次 push 到 GitHub 后自动生效;仓库已初始化 git、尚未配置远端) +**Test Framework**: 双载体——`--headless --script` 单元套件 + headless 场景探针;gdUnit4 **deferred**(D2 OQ1 关闭形态——本仓库不引第三方测试框架,需求见下「门禁」节) +**门禁**: 本地闸——`tools/run_all_gates.sh`(全量,收口唯一验收入口)/`--quick`(中段自检)。本仓库**无远端、无 CI**(`.github/workflows/` 已删除,不建不留不参考);闸清单只在本脚本定义,文档不另列 **Setup date**: 2026-09-12(2026-09-27 `7f24b640` 三层收敛:根 tests/ 并入 `game/tests/`、docs 并入 `design/dev/`、production 解散) **参考实现**: `game/smoke/`(冒烟断言 + 场景探针族——正式工程的活样板;断言计数双口径见下节) @@ -11,13 +11,28 @@ ``` tests/ unit/ # 隔离单元测试(公式/状态机/纯逻辑)——extends SceneTree + quit(code) + integration/ # L2 集成用例(跨系统闭环——与 unit/ 的隔离断言面分工,见下「分层」节) smoke/ # critical-paths.md——人工 gate 清单(/smoke-check 读取) evidence/ # 截图与人工签核记录 - run_all.gd # CI 冒烟统一入口(套件 manifest 协议,D2 AC-2b) + run_all.gd # 单元套件统一入口(套件 manifest 协议,D2 AC-2b) static_scan.sh # 静态扫描域 S1–S8 可执行子集 ``` -> integration/ 目录已删空壳(迭代 19 卡① T4/Q8 2026-10-08——彼时仅有章程 README 无实体)。章程句保留在此:将来跨系统测试(Core-Core 集成、回放确定性〔同种子步骤流逐行相同——B1 AC-33 形态〕、存档/RNG 快照往返、A1×A2 双包含端到端)落地时随批建 `tests/integration/` 迁入;各 GDD 登记的 deferral 集成用例以对侧登记为生效条件。 +## 测试三层与承载目录 + +| 层 | 判什么 | 承载 | 入闸方式 | +|---|---|---|---| +| **L1 单元** | 最单元的逻辑代码对不对(公式/状态机/纯逻辑) | `tests/unit/` | `run_all.gd` manifest(闸 02) | +| **L2 集成** | 跨系统的闭环对不对 | `tests/integration/` | 同上(与单元套件同一入口、同一协议) | +| **L3 系统/场景** | 跑起来之后 UI 与场景切换对不对 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04) | + +> **integration/ 已建承载**(迭代 29 T3——此前只有章程句无实体,空壳曾于迭代 19 卡① T4 删过一次)。 +> 首个集成用例 = `save_rng_roundtrip_test.gd`(存档⇄RNG 快照往返)。章程登记的四类集成用例里, +> 回放确定性已由 `tools/parity_anchor.py` 承担并收编为重闸 11 号条件闸件; +> Core-Core 与 A1×A2 端到端仍随属主批增量登记;各 GDD 登记的 deferral 集成用例以对侧登记为生效条件。 +> +> **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = L1; +> 需要两个以上真实系统协同并走落盘/落库 = L2;需要完整项目引导与场景树 = L3。 ## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正) @@ -29,18 +44,18 @@ tests/ ## Running Tests ```bash -# 逻辑冒烟(双载体之一)——Windows 须用 console 版 exe 取 stdout;工作目录 = game/(含 project.godot 那一层) -godot --headless --script tests/run_all.gd # 统一入口(CI 调用点) +# 单元套件(双载体之一)——Windows 须用 console 版 exe 取 stdout;工作目录 = game/(含 project.godot 那一层) +godot --headless --script tests/run_all.gd # 统一入口(本地闸 02 闸调用点) godot --headless --script tests/unit/example_test.gd # 场景探针(双载体之二)——以探针场景为运行目标,走完整项目引导(autoload 在位) godot --headless --path . res://smoke/.tscn -# CI 旁必须配外部 wall-clock 超时(引擎无死锁探测) +# 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置) timeout 120s godot --headless --script tests/run_all.gd ``` -> 单元/契约套件状态(单元测试与契约层批 2026-09-28 立基,后随迭代 7/8 增补):`_suites()` manifest 已登记实跑 **18 套件**(第 18 套 = 迭代 19 卡④提灯载体批新增 `unit/core/fog_lantern_wiring_test` 提灯装配链〔行加载/schema/只读出口双态/enc 注入面〕——本分支基点早于卡④合并时实测 17 套全绿,合并后 18)——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、core(秘宝结构冻结/断点续玩/提灯装配链)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言)、presentation(节奏队列/奖励浮层/字体资产/地形瓦片集);致命类独立进程 `expect=1`。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。 +> 单元/契约套件状态(单元测试与契约层批 2026-09-28 立基,后随迭代 7/8 增补):`_suites()` manifest 已登记实跑 **26 套件**(2026-10-16 迭代 29 T1:样板件 `example_test.gd` 退出生产 manifest,27 → 26;`fog_lantern_wiring_test` 随提灯载体退役摘除在前)——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、core(秘宝结构冻结/断点续玩/提灯装配链)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言)、presentation(节奏队列/奖励浮层/字体资产/地形瓦片集);致命类独立进程 `expect=1`。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。 **协议要点**(D2 冒烟协议): - 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)` @@ -65,13 +80,63 @@ timeout 120s godot --headless --script tests/run_all.gd | UI | Manual walkthrough OR interaction test(场景探针为首选) | `tests/evidence/` | | Config/Data | Smoke check pass | `design/qa/` | -## Static Scan(提交前双跑:本地 + CI) +## game/smoke/ 游离脚本台账(迭代 29 T7 逐个定性) + +`game/smoke/` 有 29 个 `.gd`,其中 14 个在闸(探针有 `.tscn` 宿主、被 `probe_gate.sh`/`run_smoke.py` 调用), +其余 15 个**不带 `.tscn`、不入任何闸**——它们是手动入口的测量/诊断件。逐个定性如下, +判据 = 头注自陈用途 + 有无外部引用(工具脚本/产品码/文档)。**存疑一律按在役登记并标注**(不退役)。 + +| 脚本 | 定性 | 用途与触发方式 | 结果由谁判 | +|---|---|---|---| +| `adventure_monte_carlo.gd` | **在役**(`CLAUDE.md` 在册入口) | 全冒险期望 MC;`tools/parity_anchor.py` 的对拍载体 | 工具脚本逐位比对 | +| `verifier_batch.gd` | **在役**(`CLAUDE.md` 在册入口) | `tools/balance_verifier.py` 的采集端(数值验证) | 报告落 `design/qa/` | +| `reward_expectation.gd` | **在役**(`CLAUDE.md` 在册入口) | 奖励期望测量(非封锁格分布/资源类型频次) | 人工读 stdout | +| `monte_carlo.gd` | 在役 | 战斗期望 MC(胜轮数/承伤/清图/开格分布) | 人工读 stdout | +| `auto_player.gd` | 在役(库件) | 自动玩家策略单一实现——被 `battle_driver` preload | 宿主脚本 | +| `battle_driver.gd` | 在役(库件) | 战斗驱动;被 `damage_probe` preload | 宿主脚本 | +| `level_autoplay.gd` | 在役(库件) | 被 `presentation/tune_sim_controller.gd` load | 宿主脚本 | +| `board_stat_probe.gd` | 在役 | `tools/clear_dominance_audit.py` 的取数端 | 工具脚本 | +| `calibrate_boards.gd` | 在役 | 生成器真值分母;`sim-data/analyze.py` 引用 | 工具脚本 | +| `damage_probe.gd` | 在役(诊断件) | 零承伤根因分割探针(头注自陈:非验收件·不入闸) | 人工读 stdout | +| `defense_scan.gd` | 在役(诊断件) | AC 叠层减伤读数分割(高 AC 轮 vs 基线轮) | 人工读 stdout | +| `skill_rhythm_probe.gd` | 在役(分析工具) | 技能池节奏定位(表侧池快照 + 摇号份额) | 人工读 stdout | +| `rhythm_batch.gd` | 在役(批量采集) | 技能池节奏批量读数(06 在册项的机器半边) | 人工/工具 | +| `dump_battle.gd` | **在役·一次性** | 打印一场完整战斗的步骤流与棋盘摘要(肉眼验收归因质量) | 人工(用完即止) | +| `experiment_density.gd` | **在役·一次性** | 不同 mine_density 下的推理率/残余挖掘量取数(历史调参用) | 人工(用完即止) | + +> **纪律**:本表是这些件的唯一在册处。新增手动测量脚本必须同批登记(用途 + 触发方式 + 结果由谁判); +> 退役走 `design/dev/doc-conventions.md` 第 10 节退役规则,禁止「没看见=死件」式清理。 + +## Static Scan(提交前本地跑) `tests/static_scan.sh` 实现静态扫描域 S1–S8 的可执行子集;规则全集与动机见 `design/dev/architecture/architecture.md`「测试与质量域」节。新违规案例必须**先补扫描规则再修码**。 -## CI +## 本地闸(唯一验收门禁) -Tests run automatically on every push to `main` and on every pull request. -A failed test suite blocks merging. **gdUnit4 安装说明(deferred,届时启用)**: -AssetLib → GdUnit4 → Install → Project Settings → Plugins 勾选 → 重启编辑器 → 确认 `res://addons/gdunit4/`。 +本仓库无远端、无 CI——**验收只在本地发生**,跑 `tools/run_all_gates.sh`(全量)是收口唯一入口, +跑完写回执 `game/tests/.gates/receipt.json`,合回 master 由 worktree-guard 校验回执。 +闸清单的唯一定义处就是那个脚本,本文档不另列清单(清单散落多份文档正是历史漏跑闸的成因)。 + +**判定三硬闸**(各闸一律适用,缺一即判定有洞): +①**退出码为唯一权威判据**——日志里的 PASS 串只是日志,不作判据; +②**脚本错误扫描命中即红**——GDScript 的 `SCRIPT ERROR` / `Parse Error` 不改进程退出码、 +不计失败数,且会静默中断所在函数的剩余断言,只能由外部扫输出(闸件 `tools/error_scan.py`, +错误标记与 `tools/run_smoke.py` 同源); +③**断言数为零即红**——`test_kit.gd` 的 `finish()` 在零断言时返回专用退出码 2(与「断言失败=1」 +区分,避免致命类套件的 expect 协议把它当成预期结果放过)。 + +**gdUnit4 维持 deferred**:不引入第三方测试框架,本仓库在既有双载体(`--script` 套件 + 探针场景)内解决。 + +## 术语(本项目内的唯一语义) + +| 词 | 语义 | 载体 | +|---|---|---| +| **冒烟(smoke)** | **秒级存活检查**——能否启动、能否加载表、能否进主屏。判断「这版还活着吗」,不判断「这版对不对」 | `game/smoke/` 的存活面 | +| **单元套件** | 隔离的公式/状态机/纯逻辑断言面,逐套件独立进程实跑 | `game/tests/unit/`、`run_all.gd` | +| **场景探针** | 以探针场景为运行目标、走完整项目引导(autoload 在位)的 UI/场景断言 | `game/smoke/*.tscn`、`probe_gate.sh` | +| **集成用例** | 跨系统测试(Core-Core/回放确定性/存档⇄RNG 往返/端到端) | `game/tests/integration/` | + +> ⚠ **两处历史遗留命名,读代码时别被字面骗了**:`tools/run_smoke.py` 之名来自冒烟,但**它承载的是 +> 系统级断言面**(1127 项检查,远不止存活检查);`game/smoke/` 之名同理,里面既有存活面也有 +> 系统级断言与场景探针。判定「是不是冒烟」看语义,不看文件名。 diff --git a/game/tests/golden/README.md b/game/tests/golden/README.md new file mode 100644 index 00000000..9988a44c --- /dev/null +++ b/game/tests/golden/README.md @@ -0,0 +1,20 @@ +# 视觉 golden 基线(渲染闸的比对基准) + +四张基线:棋盘三图(`board_L1_dungeon` / `board_L2_underdark` / `board_L3_deep_underdark`, +裁剪区 900×760)+ 启动态菜单(`menu_boot`,全屏)。比对判据与工具见 `tools/golden_compare.py` +(逐像素灰度差 >16 的像素占比 ≤2%)。 + +## 时效判定纪律(迭代 29 T7 立) + +基线冻结于 2026-09-30(v3.5 视觉落码后)。**判定先于动作**:每代换代面(视觉/素材/布局改动) +跑批时先跑一次渲染闸看差异落不落在裁剪区与容差内—— + +- **判有效**(changed 远低于 2% 阈值):基线**留在位并在此加注一次判定记录**,不得顺手重制; +- **判失效**(changed 超阈值或换代面落在裁剪区外):按迭代 26 T6 先例**显式重制 + 呈报 + 复闸**, + 禁止先动后判、禁止用「重制一下就绿了」掩盖真实视觉回归。 + +## 判定记录 + +- 2026-10-16(迭代 29 T7):**判有效**。三代换代面(棋盘三图 + 启动态菜单)实测 + changed = 0.00% / 0.00% / 0.00% / 0.15%,均远低于 2% 阈值 ⇒ 基线留在位,不重制。 + (本批零视觉改动,判定属常规复核。) diff --git a/game/tests/integration/save_rng_roundtrip_test.gd b/game/tests/integration/save_rng_roundtrip_test.gd new file mode 100644 index 00000000..edfba694 --- /dev/null +++ b/game/tests/integration/save_rng_roundtrip_test.gd @@ -0,0 +1,132 @@ +# tests/integration/save_rng_roundtrip_test.gd — L2 集成用例:存档 ⇄ RNG 快照往返 +# 跨系统面 = SaveManager(落盘 + 版本迁移链)× AdventureRunner(断点消费)× +# RngService(种子/取数号位续跑)× BoardGenerator(开板确定性)。 +# 与 tests/unit/core/breakpoint_resume_test.gd 的分工:那件是单测,只验「断点写读 + 首战开板一致」; +# 本件验的是**跨系统闭环**——盘上往返(含旧版本档经迁移链)之后,断点仍能被冒险层消费且 RNG 续跑 +# 零跳号,是「玩家的旧存档还能不能接着打」这条真实路径。 +# 运行:godot --headless --path game --script res://tests/integration/save_rng_roundtrip_test.gd +extends SceneTree + +const SaveManagerScript := preload("res://core/save_manager.gd") +const AdventureRunnerScript := preload("res://core/adventure_runner.gd") +const RngServiceScript := preload("res://core/rng_service.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + +const SEED := 4242 +const SAVE_PATH := "user://test_integration_save_rng.json" + +var _db: ConfigDb +var _disk: String + + +func _init() -> void: + _disk = ProjectSettings.globalize_path(SAVE_PATH) + _clear_disk() + _db = load("res://core/config_db.gd").new() as ConfigDb + if not _db.load_tables("res://data/tables", false): + Kit.check("数值表加载", false) + quit(Kit.finish("save_rng_roundtrip")) + return + _test_disk_roundtrip_preserves_breakpoint() + _test_old_version_save_migrates_and_resumes() + _clear_disk() + quit(Kit.finish("save_rng_roundtrip")) + + +# ---- 用例一:盘上往返保真——写档再读回,断点逐键零漂移 ---- +func _test_disk_roundtrip_preserves_breakpoint() -> void: + var rng := RngServiceScript.new() + var sm := SaveManagerScript.new(SAVE_PATH) + var adr := AdventureRunnerScript.new(_db, rng) + adr.set_save_manager(sm) + Kit.check_quiet(adr.start_adventure(SEED, {"hero_override": {}, "treasure_unlocked": {}}), + "往返用例:冒险启动") + Kit.check_quiet(sm.has_breakpoint(), "往返用例:入口停点断点在档") + Kit.check_quiet(sm.write_to_disk(), "往返用例:断点写盘") + + var before: Dictionary = sm.breakpoint_snapshot() + var sm2 := SaveManagerScript.new(SAVE_PATH) + Kit.check_quiet(sm2.load_from_disk(), "往返用例:自盘读回受理") + Kit.check_quiet(sm2.has_breakpoint(), "往返用例:读回后断点仍在档") + var after: Dictionary = sm2.breakpoint_snapshot() + Kit.check_quiet(int(after.get("master_seed", 0)) == SEED, "往返用例:主种子往返保真") + Kit.check_quiet(int(after.get("rng_counter", -1)) == int(before.get("rng_counter", -2)), + "往返用例:RNG 取数号位往返保真(存档不得重耗/跳号)") + var bonus_before: Dictionary = before.get("bonus", {}) as Dictionary + var bonus_after: Dictionary = after.get("bonus", {}) as Dictionary + var bonus_same := bonus_after.size() == bonus_before.size() + for k in bonus_before: + if int(bonus_after.get(k, -99999)) != int(bonus_before[k]): + bonus_same = false + Kit.check_quiet(bonus_same, "往返用例:永久强化账本往返保真(逐键比整数值——JSON 往返不改值)") + Kit.check_quiet(int(after.get("hero_hp", -1)) == int(before.get("hero_hp", -2)), + "往返用例:生命账本往返保真") + + +# ---- 用例二:旧版本档经迁移链后仍可续玩(单测不覆盖的跨系统集成面)---- +func _test_old_version_save_migrates_and_resumes() -> void: + var rng := RngServiceScript.new() + var sm := SaveManagerScript.new(SAVE_PATH) + var adr := AdventureRunnerScript.new(_db, rng) + adr.set_save_manager(sm) + if not adr.start_adventure(SEED, {"hero_override": {}, "treasure_unlocked": {}}): + Kit.check("旧档用例:冒险启动前置", false) + return + var runner = adr.runner() + var c0: int = rng.counter() + var cand: Array = runner.next_candidate_ids() + if cand.is_empty(): + Kit.check("旧档用例:入口停点有候选路", false) + return + var fp_before := _board_fingerprint(runner) + var hp_before: int = runner.hero_hp + sm.write_to_disk() + + # 降级为上一版格式档:抹掉当前版本新增域并回退版本号,模拟玩家手里的旧档。 + var raw := FileAccess.get_file_as_string(_disk) + var parsed: Variant = JSON.parse_string(raw) + if not (parsed is Dictionary): + Kit.check("旧档用例:盘上档可解析", false) + return + var old: Dictionary = (parsed as Dictionary).duplicate(true) + old["version"] = 6 + var meta: Dictionary = old.get("meta", {}) as Dictionary + meta.erase("forge_routes") + old["meta"] = meta + var f := FileAccess.open(_disk, FileAccess.WRITE) + if f == null: + Kit.check("旧档用例:旧档可落盘", false) + return + f.store_string(JSON.stringify(old)) + f.close() + + var sm_old := SaveManagerScript.new(SAVE_PATH) + Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载") + Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉") + var forge: Dictionary = sm_old.forge_routes_snapshot() + Kit.check_quiet(str(forge.get("weapon", "")) == "sword", + "旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)") + + # 旧档断点仍能被冒险层消费,且 RNG 续跑零跳号、开板与中断前逐位一致 + var bp: Dictionary = sm_old.breakpoint_snapshot() + var rng2 := RngServiceScript.new() + var adr2 := AdventureRunnerScript.new(_db, rng2) + Kit.check_quiet(adr2.resume_adventure(bp, {"hero_override": {}, "treasure_unlocked": {}}), + "旧档用例:旧档断点续玩受理") + Kit.check_quiet(int(adr2.master_seed) == SEED, "旧档用例:续玩主种子回填") + Kit.check_quiet(rng2.counter() == c0, "旧档用例:续玩取数号位 = 断点号位(零重耗零跳号)") + var runner2 = adr2.runner() + Kit.check_quiet(runner2.hero_hp == hp_before, "旧档用例:续玩生命账本回填") + Kit.check_quiet(_board_fingerprint(runner2) == fp_before, + "旧档用例:续玩停点态与中断前逐位一致") + + +func _board_fingerprint(runner) -> String: + # 停点态指纹:候选路 + 生命 + 取数号位(无战斗对象时的可比面) + var cand: Array = runner.next_candidate_ids() + return "%s|%d" % [str(cand), runner.hero_hp] + + +func _clear_disk() -> void: + if FileAccess.file_exists(_disk): + DirAccess.remove_absolute(_disk) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index af00ae60..48f95512 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -1,20 +1,31 @@ # tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b) -# 用法:godot --headless --path game --script res://tests/run_all.gd (CI 侧包装外部超时:timeout 120s …) +# 用法:godot --headless --path game --script res://tests/run_all.gd (本地闸 02 闸包装外部超时:timeout 120s …) # 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。 # 执行形态:逐套件起独立 Godot 子进程实跑 #(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符, # 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。 -# 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。 +# 场景探针(.tscn 载体)由本地闸(probe_gate.sh)单独步骤调用,不经本入口。 extends SceneTree +# 脚本错误标记与 tools/run_smoke.py 的 ERROR_MARKERS 同源(GDScript 无脚本错误陷阱 API, +# 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。 +const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"] + func _init() -> void: print("[run_all] unit/contract suite entry — %d suites" % _suites().size()) if DisplayServer.get_name() != "headless": push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)") var failures := 0 + var ran := 0 for suite in _suites(): - failures += _run_suite(suite) + failures += _run_suite(suite, ran) + ran += 1 + # 实跑套件数校验:manifest 有条目却被静默跳过(早退/continue)必须判红,不能只凭 failures。 + if ran != _suites().size(): + push_error("[run_all] 实跑套件数 %d ≠ manifest 条目数 %d——有套件未跑" % [ran, _suites().size()]) + quit(1) + return if failures > 0: push_error("[run_all] %d 个套件失败" % failures) quit(1) @@ -23,15 +34,18 @@ func _init() -> void: quit(0) -# 套件清单:{ "path": String, "expect": int };致命类(期望非 0)独立进程断言退出码。 +# 套件清单:{ "path": String, "expect": int, "negative"?: bool };致命类(期望非 0)独立进程断言退出码。 func _suites() -> Array: return [ - {"path": "res://tests/unit/example_test.gd", "expect": 0}, + # example_test.gd 是协议活样板(tests/unit/README 明示),不进生产 manifest—— + # 样板件占位断言跑绿没有信息量,且占据「27 套件」计数。文件保留作模板(同类先例见下 fog_lantern 摘除行)。 {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/forge_table_test.gd", "expect": 0}, # 迭代 23 锻造工坊批 T1——六路线 schema 正反(V1)+特效留空(V11)+几何价格梯(V2 半边) {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1}, {"path": "res://tests/unit/data_config/treasure_target_violation_test.gd", "expect": 1}, # 迭代 7 T1——真视窄域护栏 - {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1}, # 迭代 7 T1——秘宝双表校验 + # negative=true=负面套件:坏表/违规输入是它的测试目标,输出里必然带 Parse Error, + # 脚本错误扫描对它属误伤——豁免只放行这一件,且仅限「错误扫描」一闸(退出码与断言下限照判)。 + {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 迭代 7 T1——秘宝双表校验 {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, {"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 迭代 7 T7——秘宝结构冻结断言面 {"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 迭代 8 卡①/T9——断点往返/续玩确定性/幂等/退出清断点 @@ -54,22 +68,78 @@ func _suites() -> Array: {"path": "res://tests/unit/core/feat_vouchers_test.gd", "expect": 0}, # 迭代 20 T4——凭证五件+留任二件(V9 路由逐件/F-1 存量账/V10 留任路由+AP 属性轴) {"path": "res://tests/unit/core/settle_segments_test.gd", "expect": 0}, # 迭代 27 T2——结算五段 Segment(V4:直接 new/编排器接线/真实一轮走段序) {"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 迭代 27 T4——模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗) + # ---- L2 集成层(迭代 29 T3 建承载——跨系统用例,与 unit/ 的隔离断言面分工)---- + {"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性) + # ---- 迭代 29 T5 覆盖补洞:此前零指名的 core 四件与呈现层两件 ---- + {"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽) + {"path": "res://tests/unit/core/minesweeper_rules_test.gd", "expect": 0}, # 排雷规则(装板不变量/挖的 face 规则/揭示推进/插旗/目录外拒) + {"path": "res://tests/unit/core/config_db_resolver_test.gd", "expect": 0}, # 配置解析(ceil 偏难语义/行集行序/难度 clamp) + {"path": "res://tests/unit/core/config_db_cross_validator_test.gd", "expect": 0}, # 跨表校验(正向零错/悬空 skill_id/side 反向) + {"path": "res://tests/unit/presentation/hud_presenter_test.gd", "expect": 0}, # HUD 投影文案(逐拒绝原因/兜底不空) + {"path": "res://tests/unit/presentation/battle_narrator_test.gd", "expect": 0}, # 战斗叙述(数值带入/胜负互异/收束态恒定) ] -func _run_suite(suite: Dictionary) -> int: +func _run_suite(suite: Dictionary, idx: int) -> int: var path: String = suite.get("path", "") var expect: int = int(suite.get("expect", 0)) if path.is_empty(): push_error("[run_all] suite missing path") return 1 - var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://"), "--script", path]) + var bin := _engine_bin() + if bin == "": + push_error("[run_all] 未找到 console 版引擎二进制(设 GODOT_BIN 指向 *_console.exe)——" + + "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地") + return 1 + # 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空 + #(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。 + var proj := ProjectSettings.globalize_path("res://") + var log := proj + "/tests/.gates/run_all_%02d.log" % idx + DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates") + var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log] var out := PackedStringArray() - var code: int = OS.execute(OS.get_executable_path(), args, out, true, false) - var text := "\n".join(out) - print(text) + var code: int = OS.execute("cmd.exe", PackedStringArray(["/c", cmd]), out, true, false) + var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else "" + var negative := bool(suite.get("negative", false)) + # 负面套件(坏表/违规输入是它的测试目标)全文折叠,只回显结论行—— + # 它输出里的 Parse Error 是预期产物,回显出去会让编排层的日志扫描误判成真实脚本错误。 + if negative: + print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path) + else: + print(text) + if not negative and _has_script_error(text): + push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)]) + return 1 if code != expect: push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect]) return 1 print("[run_all] PASS %s (exit=%d)" % [path, code]) return 0 + + +# 引擎二进制必须取 console 版:OS.get_executable_path() 返回的是 GUI 子系统 exe +#(Godot_v4.7.2-stable_win64.exe),它起的子进程 stdout/stderr 句柄无效——输出全丢, +# 脚本错误扫描就成了扫空气。GODOT_BIN 优先,其次按命名推导 *_console.exe。 +func _engine_bin() -> String: + var env := OS.get_environment("GODOT_BIN") + if env != "" and FileAccess.file_exists(env): + return env + var derived := OS.get_executable_path().replace(".exe", "_console.exe") + if FileAccess.file_exists(derived): + return derived + return "" + + +func _has_script_error(text: String) -> bool: + return _script_error_line(text) != "" + + +func _script_error_line(text: String) -> String: + for ln in text.split("\n"): + var s := String(ln).strip_edges() + if s.is_empty(): + continue + for marker in ERROR_MARKERS: + if s.contains(marker): + return s.substr(0, mini(s.length(), 160)) + return "" diff --git a/game/tests/static_scan.sh b/game/tests/static_scan.sh index 58e84f52..d562289a 100644 --- a/game/tests/static_scan.sh +++ b/game/tests/static_scan.sh @@ -13,6 +13,28 @@ fi fail=0 +# ── 扫描路径单点 + 零命中自检(迭代 29 T6)── +# 静默空转比红更危险:S3 曾把路径写成裸 `core`(仓根无此目录,实际是 game/core), +# 恒零命中恒绿、什么都不扫,直到本批才被发现。故每条规则的扫描路径必须是单点变量, +# 且规则自己先数一遍扫描面文件数——数为零一律判红(这样路径写歪时自检会跟着一起歪、一起红)。 +DIR_CORE="game/core" +DIR_PRES="game/presentation" + +scan_files() { # scan_files <目录> — 该目录下 .gd 文件数 + find "$1" -name '*.gd' 2>/dev/null | wc -l +} + +require_scan_face() { # require_scan_face <规则名> <目录> — 扫描面为空即判红 + local n + n=$(scan_files "$2") + if [ "$n" -eq 0 ]; then + echo "[static_scan] $1 FAIL: 扫描面为空——$2 下零个 .gd(规则路径写错导致的静默空转)" + fail=1 + return 1 + fi + return 0 +} + # S9 — 类型闸(迭代 27 T5——code-conventions §4 机器闸:产品码无类型容器/有值返回缺类型/参数缺类型) if python tools/audit_type_hints.py; then echo "[static_scan] S9 ok" @@ -33,22 +55,40 @@ fi # S1/S2 — 玩法代码禁 randomize()/自建 RandomNumberGenerator/全局随机函数(绕开 RngService 的口子) # 2026-09-24 首次实跑收敛(转正结构批):违规签名=裸 randomize/rand* 调用(实例方法 .randi() 排除) # + RngService 之外的 RandomNumberGenerator.new();接收服务注入流的类型标注不属违规(core 五文件 15 处实证) +if require_scan_face "S1/S2" "$DIR_CORE"; then hits=$(grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' \ - game/core game/presentation --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | grep -v 'rng-seed-exempt' | wc -l) -hits2=$(grep -rn 'RandomNumberGenerator\.new()' game/core game/presentation --include='*.gd' 2>/dev/null | grep -vc rng_service) -if [ "$hits" -gt 0 ] || [ "$hits2" -gt 0 ]; then echo "[static_scan] S1/S2 FAIL: 全局随机/RNG 自建命中 $hits+$hits2 处"; grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' game/core game/presentation --include='*.gd' 2>/dev/null; grep -rn 'RandomNumberGenerator\.new()' game/core game/presentation --include='*.gd' 2>/dev/null | grep -v rng_service; fail=1; else echo "[static_scan] S1/S2 ok"; fi + "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | grep -v 'rng-seed-exempt' | wc -l) +hits2=$(grep -rn 'RandomNumberGenerator\.new()' "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null | grep -vc rng_service) +if [ "$hits" -gt 0 ] || [ "$hits2" -gt 0 ]; then echo "[static_scan] S1/S2 FAIL: 全局随机/RNG 自建命中 $hits+$hits2 处"; grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null; grep -rn 'RandomNumberGenerator\.new()' "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null | grep -v rng_service; fail=1; else echo "[static_scan] S1/S2 ok"; fi +fi # S4 — core/ 禁 extends Control/Node2D、禁 .tscn preload(headless 债) -hits=$(grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' game/core --include='*.gd' 2>/dev/null | wc -l) -if [ "$hits" -gt 0 ]; then echo "[static_scan] S4 FAIL: core 引擎/UI 依赖命中 $hits 处"; grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' game/core --include='*.gd' 2>/dev/null; fail=1; else echo "[static_scan] S4 ok"; fi +if require_scan_face "S4" "$DIR_CORE"; then +hits=$(grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' "$DIR_CORE" --include='*.gd' 2>/dev/null | wc -l) +if [ "$hits" -gt 0 ]; then echo "[static_scan] S4 FAIL: core 引擎/UI 依赖命中 $hits 处"; grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' "$DIR_CORE" --include='*.gd' 2>/dev/null; fail=1; else echo "[static_scan] S4 ok"; fi +fi # S3 — core/ 禁 ConfigDB 外 data 表加载(schema class_name 直引豁免) -hits=$(grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' core --include='*.gd' 2>/dev/null \ +# 路径修正(迭代 29 T6):原扫裸 `core`——仓根无此目录,恒零命中恒绿(静默空转);实际为 game/core。 +# 豁免:①注释行 ②game/core/config_db.gd 自身(数值单一事实源的正主,它的单点 load 是本规则的例外)。 +if require_scan_face "S3" "$DIR_CORE"; then +hits=$(grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' "$DIR_CORE" --include='*.gd' 2>/dev/null \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' \ + | grep -v "$DIR_CORE/config_db.gd" \ | grep -v 'res://tests/' | wc -l) -if [ "$hits" -gt 0 ]; then echo "[static_scan] S3 WARNING(人工复核——class_name 直引误报需逐条排除): $hits 处"; grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' core --include='*.gd' 2>/dev/null; fi +if [ "$hits" -gt 0 ]; then echo "[static_scan] S3 WARNING(人工复核——class_name 直引误报需逐条排除): $hits 处"; grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' "$DIR_CORE" --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | grep -v "$DIR_CORE/config_db.gd"; else echo "[static_scan] S3 ok(扫描面 $DIR_CORE 共 $(scan_files "$DIR_CORE") 个 .gd,非静默空转)"; fi +fi + +# S5 — core/ 禁裸引用 autoload 全局标识符(ConfigDB / RngService 直接写 = --script 模式 parse error) +# 形态:标识符后紧跟 `.`(成员访问)才算裸引用;排除注释行,并排除被引号/斜杠包裹的字符串字面量 +# (如 "[ConfigDB/%s]" 日志前缀、"/root/ConfigDB" 取节点路径)——那是文案与路径,不是标识符引用。 +if require_scan_face "S5" "$DIR_CORE"; then +hits=$(grep -rnE '(^|[^.[:alnum:]_"/])(ConfigDB|RngService)[[:space:]]*\.' "$DIR_CORE" --include='*.gd' 2>/dev/null \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | wc -l) +if [ "$hits" -gt 0 ]; then echo "[static_scan] S5 FAIL: core 裸引用 autoload 全局标识符命中 $hits 处(--script 模式 parse error)"; grep -rnE '(^|[^.[:alnum:]_"/])(ConfigDB|RngService)[[:space:]]*\.' "$DIR_CORE" --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#'; fail=1; else echo "[static_scan] S5 ok(扫描面 $DIR_CORE 共 $(scan_files "$DIR_CORE") 个 .gd)"; fi +fi # TODO(正式工程落地后精化为扫描配置并升格为硬失败): -# S5 core/ 禁裸引用 ConfigDB/RngService 全局标识符(--script 模式 parse error;grep 近似误报多,需 AST 级或精确白名单) # S6 core↔core 信号连接只许方法 Callable(禁 lambda 捕获 self);发射者禁反向引用 # S7 B2/B3/B4 API 符号命中文件 ⊆ B1 文件集(编排唯一性) # S8 结算路径 await 零命中(B1 结算/编排文件集) diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd index 36901a8d..047222f7 100644 --- a/game/tests/unit/contracts/system_contracts_test.gd +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -11,6 +11,7 @@ extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") const RngServiceScript := preload("res://core/rng_service.gd") const StatusContainerScript := preload("res://core/status_container.gd") +const UnitContainerScript := preload("res://core/unit_container.gd") const DamageControllerScript := preload("res://core/damage_controller.gd") const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 @@ -86,23 +87,13 @@ func _test_ticket_nine_columns() -> void: func _bare_controller(): var db = ConfigDBScript.new() - # 只取 open_ticket 纯构造面——容器/依赖传桩(构造期不消费) - var units = UnitContainerStub.new() - var effects = StatusContainerStub.new() - return DamageControllerScript.new(db, RngStub.new(), units, effects) - - -# 桩容器:构造期零消费(DamageController._init 只 connect unit_died 信号——桩需同名信号) -class UnitContainerStub: - signal unit_died(u: Dictionary) - - -class StatusContainerStub: - pass - - -class RngStub: - pass + # 只取 open_ticket 纯构造面——四个依赖一律传真实实例,不用空桩: + # DamageController 的 db/rng/units/effects 全是定型标注,传无类型的空桩会触发 + # 「Trying to assign value of type '' to a variable of type '…'」脚本错误——该错误不改退出码, + # 却让赋值静默失败(成员留 null),脚本错误扫描落地后必红。三者均可零消费构造。 + var units = UnitContainerScript.new(db) + var effects = StatusContainerScript.new(db) + return DamageControllerScript.new(db, RngServiceScript.new(), units, effects) # ---- B4→B3:增益授予布尔受理口径(受理/拒绝二元面 + 投影一致性) ---- diff --git a/game/tests/unit/core/board_generator_test.gd b/game/tests/unit/core/board_generator_test.gd new file mode 100644 index 00000000..f593cdaa --- /dev/null +++ b/game/tests/unit/core/board_generator_test.gd @@ -0,0 +1,97 @@ +# tests/unit/core/board_generator_test.gd — 棋盘生成直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 生成的棋盘自洽:尺寸与表一致、雷数在域、数字恒等于八邻雷数、同种子逐位可复现、 +# 可推理性度量与清图判定自洽。期望值一律由盘上数出来(零玩法数值字面量)。 +# 运行:godot --headless --path game --script res://tests/unit/core/board_generator_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const BoardGeneratorScript := preload("res://core/board_generator.gd") +const RngServiceScript := preload("res://core/rng_service.gd") + +var _db: ConfigDb + + +func _init() -> void: + _db = load("res://core/config_db.gd").new() as ConfigDb + if not _db.load_tables("res://data/tables", false): + Kit.check("数值表加载", false) + quit(Kit.finish("board_generator_test")) + return + _test_generate_shape_and_mines() + _test_digits_equal_neighbor_mine_count() + _test_same_seed_bit_identical() + _test_clearable_and_inferability_selfconsistent() + quit(Kit.finish("board_generator_test")) + + +func _stream(seed0: int) -> RandomNumberGenerator: + var rng := RngServiceScript.new() + rng.set_master_seed(seed0) + rng.new_adventure(seed0) + return rng.open_stream("board_gen") + + +func _generate(L: int, D: int, seed0: int) -> BoardData: + return BoardGeneratorScript.new().generate(L, D, _stream(seed0), _db) + + +func _test_generate_shape_and_mines() -> void: + var b := _generate(1, 0, 71000) + if b == null: + Kit.check("生成返回棋盘", false) + return + var row: Dictionary = _db.resolve_board_gen(1, 0) + var want_w: int = int(row.get("board_width", 0)) + var want_h: int = int(row.get("board_height", 0)) + Kit.check("棋盘尺寸与 board_gen 表一致(%dx%d)" % [want_w, want_h], + b.width == want_w and b.height == want_h) + Kit.check("雷数非零且不超过总格数", b.mines.size() > 0 and b.mines.size() < b.width * b.height) + var all_in_bounds := true + for c in b.mines: + if not b.in_bounds(c as Vector2i): + all_in_bounds = false + Kit.check("全部雷格在界内", all_in_bounds) + Kit.check("生成期指标集已落盘(metrics 非空)", not b.metrics.is_empty()) + + +func _test_digits_equal_neighbor_mine_count() -> void: + var b := _generate(1, 1, 72000) + if b == null: + Kit.check("生成返回棋盘(数字段)", false) + return + var bad := 0 + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if b.is_mine(c): + continue + var want := 0 + for n in b.neighbors8(c): + if b.is_mine(n as Vector2i): + want += 1 + if int(b.digit_of(c)) != want: + bad += 1 + Kit.check("每个安全格的数字恒等于八邻雷数(全盘逐格复算,错 %d 格)" % bad, bad == 0) + + +func _test_same_seed_bit_identical() -> void: + var a := _generate(2, 0, 73000) + var b := _generate(2, 0, 73000) + if a == null or b == null: + Kit.check("生成返回棋盘(确定性段)", false) + return + Kit.check("同种子生成的雷集逐位一致", str(a.mines) == str(b.mines)) + Kit.check("同种子生成的数字集逐位一致", str(a.digits) == str(b.digits)) + + +func _test_clearable_and_inferability_selfconsistent() -> void: + var b := _generate(1, 0, 74000) + if b == null: + Kit.check("生成返回棋盘(可推理段)", false) + return + var m := BoardGeneratorScript.new().measure_inferability(b) + Kit.check("可推理性度量返回非空指标集", not m.is_empty()) + var clearable: bool = BoardGeneratorScript.new().is_clearable(b) + var safe_total := b.width * b.height - b.mines.size() + Kit.check("清图判定与安全格总数同在(安全格 %d 个,可清=%s)" % [safe_total, str(clearable)], + safe_total > 0) diff --git a/game/tests/unit/core/config_db_cross_validator_test.gd b/game/tests/unit/core/config_db_cross_validator_test.gd new file mode 100644 index 00000000..9868a779 --- /dev/null +++ b/game/tests/unit/core/config_db_cross_validator_test.gd @@ -0,0 +1,84 @@ +# tests/unit/core/config_db_cross_validator_test.gd — 跨表校验直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 敌人技能池跨表链接校验:悬空 skill_id 必报、施放类 side 反向挂接必报、 +# 池构成纪律(普攻恰一行)越界必报、全对齿时零错(正向面防误伤)。 +# 表对象由夹具直构(不碰生产表),期望值由注入内容数出来。 +# 运行:godot --headless --path game --script res://tests/unit/core/config_db_cross_validator_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ValidatorScript := preload("res://core/config_db_cross_validator.gd") +const SkillTableScript := preload("res://data/schemas/skill_table.gd") +const EnemyTableScript := preload("res://data/schemas/enemy_table.gd") +const StatusTableScript := preload("res://data/schemas/status_effect_table.gd") + + +func _init() -> void: + _test_all_links_valid() + _test_dangling_skill_id() + _test_cast_side_mismatch() + quit(Kit.finish("config_db_cross_validator_test")) + + +func _skill(rows: Array) -> SkillTable: + var t := SkillTableScript.new() + t.rows.assign(rows) + return t + + +func _enemy(rows: Array) -> EnemyTable: + var t := EnemyTableScript.new() + t.rows.assign(rows) + return t + + +func _status(rows: Array) -> StatusEffectTable: + var t := StatusTableScript.new() + t.rows.assign(rows) + return t + + +func _cats(errs: Array) -> Array[String]: + var out: Array[String] = [] + for e in errs: + out.append(String((e as Dictionary).get("category", ""))) + return out + + +# ---- 正向:技能池全对齿(普攻恰一行、buff 挂敌方侧)应零错,防校验误伤 ---- +func _test_all_links_valid() -> void: + var skills := _skill([ + {"skill_id": "attack", "type": "basic"}, + {"skill_id": "raise_morale", "type": "buff", "buff_ref": "st_strong"}, + ]) + var enemies := _enemy([ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"skill_id": "attack", "weight": 9}, {"skill_id": "raise_morale", "weight": 3}]}, + ]) + var status := _status([{"effect_id": "st_strong", "target_side": "enemy"}]) + var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status) + Kit.check("全对齿的技能池零错(正向面——实际报错 %s)" % str(_cats(errs)), errs.is_empty()) + + +# ---- 负向:技能池引用表里不存在的 skill_id 必须报 ---- +func _test_dangling_skill_id() -> void: + var skills := _skill([{"skill_id": "attack", "type": "basic"}]) + var enemies := _enemy([ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"skill_id": "attack", "weight": 9}, {"skill_id": "not_in_table", "weight": 3}]}, + ]) + var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, null) + Kit.check("池内悬空 skill_id 被报(跨表护栏不静默放过)", not errs.is_empty()) + Kit.check("悬空项的错误类别可定位(category 非空)", + not errs.is_empty() and String((errs[0] as Dictionary).get("category", "")) != "") + + +# ---- 负向:buff 挂到主角侧状态(side 反向)必须报 ---- +func _test_cast_side_mismatch() -> void: + var skills := _skill([{"skill_id": "raise_morale", "type": "buff", "buff_ref": "st_hero_side"}]) + var enemies := _enemy([ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"skill_id": "raise_morale", "weight": 3}]}, + ]) + var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}]) + var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status) + Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side")) diff --git a/game/tests/unit/core/config_db_resolver_test.gd b/game/tests/unit/core/config_db_resolver_test.gd new file mode 100644 index 00000000..820392e8 --- /dev/null +++ b/game/tests/unit/core/config_db_resolver_test.gd @@ -0,0 +1,85 @@ +# tests/unit/core/config_db_resolver_test.gd — 配置解析直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = ceil 进度键解析语义(2026-09-18 用户拍板:向上取整保证偏难)、行集多行的行序保序、 +# 难度修饰乘数的 clamp 域。行集由夹具注入,不碰生产表——期望值由注入行数出来。 +# 运行:godot --headless --path game --script res://tests/unit/core/config_db_resolver_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ResolverScript := preload("res://core/config_db_resolver.gd") + +var _diags: Array[String] = [] +var _resolver: ConfigDbResolver + + +func _init() -> void: + _resolver = ResolverScript.new( + func(level, category, message) -> void: # 诊断通道签名按调用实参收(首参为表 id 字符串) + _diags.append("%s/%s/%s" % [str(level), str(category), str(message)]), + func() -> Array: return _board_gen_rows(), + func() -> Array: return _enemy_rows(), + func(): return _difficulty_table(), + ) + _test_ceil_progress_key() + _test_rowset_order_preserved() + _test_difficulty_clamp() + quit(Kit.finish("config_db_resolver_test")) + + +func _board_gen_rows() -> Array: + return [ + {"level_index": 1, "node_depth": 0, "board_width": 10}, + {"level_index": 1, "node_depth": 3, "board_width": 12}, + {"level_index": 2, "node_depth": 0, "board_width": 14}, + ] + + +func _enemy_rows() -> Array: + return [ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin"}, + {"level_index": 1, "node_depth": 0, "enemy_id": "bugbear"}, + {"level_index": 1, "node_depth": 5, "enemy_id": "hobgoblin"}, + ] + + +func _difficulty_table(): + var rows := [ + {"level_index": 1, "node_depth": 0, "hp_mult": 99.0, "atk_mult": 0.01}, + ] + return _FakeDifficulty.new(rows) + + +# ceil 语义:请求 (1,1) 落在 d=0 与 d=3 之间,向上取到 d=3(偏难),不是向下取 d=0 +func _test_ceil_progress_key() -> void: + var hit: Dictionary = _resolver.resolve_board_gen(1, 1) + Kit.check("ceil 进度键:请求 (1,1) 命中 (1,3) 而非 (1,0)——偏难语义", + int(hit.get("node_depth", -1)) == 3 and int(hit.get("board_width", 0)) == 12) + var exact: Dictionary = _resolver.resolve_board_gen(2, 0) + Kit.check("精确命中:(2,0) 取本行", int(exact.get("board_width", 0)) == 14) + + +func _test_rowset_order_preserved() -> void: + var rows: Array = _resolver.resolve_enemy_rowset(1, 0) + Kit.check("行集返回同坐标全部行(2 行)", rows.size() == 2) + var ids: Array[String] = [] + for r in rows: + ids.append(String((r as Dictionary).get("enemy_id", ""))) + Kit.check("行集按表文件行序返回(goblin 先于 bugbear——行序即契约)", + ids == ["goblin", "bugbear"]) + var far: Array = _resolver.resolve_enemy_rowset(1, 4) + Kit.check("ceil 行集:请求 (1,4) 命中 (1,5)", far.size() == 1 + and String((far[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") + + +func _test_difficulty_clamp() -> void: + var m: Dictionary = _resolver.resolve_difficulty_modifiers(1, 0) + Kit.check("难度乘数越域被 clamp:hp 99 → 上限 10", absf(float(m.get("hp_mult", 0.0)) - 10.0) < 0.001) + Kit.check("难度乘数越域被 clamp:atk 0.01 → 下限 0.1", absf(float(m.get("atk_mult", 0.0)) - 0.1) < 0.001) + Kit.check("未登记坐标返回空修饰(零值不伪装成 ×1)", + _resolver.resolve_difficulty_modifiers(3, 3).is_empty()) + + +class _FakeDifficulty: + var rows: Array = [] + + func _init(rows_in: Array) -> void: + rows = rows_in diff --git a/game/tests/unit/core/minesweeper_rules_test.gd b/game/tests/unit/core/minesweeper_rules_test.gd new file mode 100644 index 00000000..1e51dbad --- /dev/null +++ b/game/tests/unit/core/minesweeper_rules_test.gd @@ -0,0 +1,112 @@ +# tests/unit/core/minesweeper_rules_test.gd — 排雷规则直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 装板后的盘面不变量与意图受理:装板零揭示、安全格余量=安全格总数、挖安全格后揭示 +# 且余量递减、插旗落旗态、禁挖面拒绝、清图判定随揭示推进。期望值一律由盘上数出来。 +# 运行:godot --headless --path game --script res://tests/unit/core/minesweeper_rules_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const MinesweeperScript := preload("res://core/minesweeper_rules.gd") +const BoardGeneratorScript := preload("res://core/board_generator.gd") +const RngServiceScript := preload("res://core/rng_service.gd") +const ActionEconomyScript := preload("res://core/action_economy.gd") + +var _db: ConfigDb +var _rules: MinesweeperRules +var _board: BoardData +var _ae: ActionEconomy + + +func _init() -> void: + _db = load("res://core/config_db.gd").new() as ConfigDb + if not _db.load_tables("res://data/tables", false): + Kit.check("数值表加载", false) + quit(Kit.finish("minesweeper_rules_test")) + return + var rng := RngServiceScript.new() + rng.set_master_seed(81000) + rng.new_adventure(81000) + _board = BoardGeneratorScript.new().generate(1, 0, rng.open_stream("board_gen"), _db) + if _board == null: + Kit.check("生成返回棋盘(前置)", false) + quit(Kit.finish("minesweeper_rules_test")) + return + # 相位:装板走 IDLE 相位(开局路径),意图受理须 Active 相位——夹具直置内部相位枚举, + # 与既有单测直读 action_economy._ap 的先例同款(写通道只经 notify_*/request_* 是产品码纪律)。 + _ae = ActionEconomyScript.new(_db) + _rules = MinesweeperScript.new(_db, _ae) + _test_install_invariants() + _ae._phase = 1 # Phase.ACTIVE——意图受理的合法相位 + _test_dig_face_rules() + _test_reveal_advances() + _test_flag_place() + _test_reject_out_of_catalog() + quit(Kit.finish("minesweeper_rules_test")) + + +func _test_install_invariants() -> void: + _rules.install_board(_board) + Kit.check("装板后 current_board 即所装棋盘", _rules.current_board() == _board) + var opened: int = _rules.revealed_cells().size() + var safe_total := _board.width * _board.height - _board.mines.size() + # 装板带初始扩张(开局安全区),揭示数非零且严格小于安全格总数——否则开局即无牌可打。 + Kit.check("装板带初始扩张揭示(%d 格,非零且未铺满安全格 %d)" % [opened, safe_total], + opened > 0 and opened < safe_total) + Kit.check("安全格余量 = 安全格总数 − 已揭示数(%d − %d)" % [safe_total, opened], + _rules.safe_remaining() == safe_total - opened) + Kit.check("装板后仍有未揭示格", _rules.has_unrevealed()) + + +func _unrevealed_safe() -> Vector2i: + var revealed := {} + for c in _rules.revealed_cells(): + revealed[c] = true + for c in _board.safe_cells(): + if not revealed.has(c): + return c as Vector2i + return Vector2i(-1, -1) + + +func _test_dig_face_rules() -> void: + var revealed_first = _rules.revealed_cells()[0] as Vector2i + var out: Array = _rules.settle_batch([{"action": "dig", "cell": revealed_first}]) + var o: Dictionary = (out[0] as Dictionary) if not out.is_empty() else {} + Kit.check("挖已揭示格被拒(board_face=revealed,禁重复挖)——载荷 %s" % str(o.get("sub_reason", "")), + String(o.get("outcome", "")) == "rejected" and String(o.get("sub_reason", "")) == "target_revealed") + # 挖受英雄邻接约束:初始扩张把英雄周围铺开后,远处安全格必然是 not_adjacent + var far := _unrevealed_safe() + var out2: Array = _rules.settle_batch([{"action": "dig", "cell": far}]) + var o2: Dictionary = (out2[0] as Dictionary) if not out2.is_empty() else {} + Kit.check("挖非相邻格被拒(邻接规则)——载荷 %s" % str(o2.get("sub_reason", "")), + String(o2.get("outcome", "")) == "rejected") + + +func _test_reveal_advances() -> void: + var c := _unrevealed_safe() + if c == Vector2i(-1, -1): + Kit.check("存在未揭示安全格(揭示推进前置)", false) + return + var before: int = _rules.safe_remaining() + var ok: bool = _rules.fx_reveal(c) + Kit.check("专长揭示受理(fx_reveal 不受邻接约束)", ok) + Kit.check("fx_reveal 后该格已揭示", _rules.is_revealed(c)) + Kit.check("fx_reveal 后安全格余量递减(%d → %d)" % [before, _rules.safe_remaining()], + _rules.safe_remaining() < before) + + +func _test_flag_place() -> void: + var c := _unrevealed_safe() + if c == Vector2i(-1, -1): + Kit.check("存在未揭示安全格(插旗前置)", false) + return + _ae.grant_ap(20) # 旗动作走行动经济扣点——夹具先给足 AP + _rules.flag_range_free = true # 插旗免邻接(信标网络装配态)——否则未揭示格多半够不着英雄 + _rules.settle_batch([{"action": "flag_place", "cell": c}]) + Kit.check("插旗后该格在旗态", _rules.has_flag(c)) + Kit.check("旗数计数 = 1", _rules.flag_count() == 1) + + +func _test_reject_out_of_catalog() -> void: + var out: Array = _rules.settle_batch([{"action": "not_a_real_action", "cell": Vector2i(0, 0)}]) + var o: Dictionary = (out[0] as Dictionary) if not out.is_empty() else {} + Kit.check("目录外意图被拒(不静默吞掉)——实际载荷 %s" % str(o).substr(0, 80), + out.size() == 1 and String(o.get("outcome", o.get("result", ""))) == "rejected") diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 9e91d816..1837847c 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -193,23 +193,29 @@ func _test_manifest_dir_sync(db) -> void: # (rowset_rule——行序即契约,R6 加载期兜底)③行集实例总数 ≤4(骰后值运行面,spawn 守卫——冒烟侧验证)。 func _test_rowset_discipline() -> void: + # 行集用 rows.assign([...]) 而非直接赋值:rows 是 Array[Dictionary] 定型属性,直接赋 untyped + # 数组字面量会触发「Invalid assignment」脚本错误——该错误不改退出码,却会**中断本函数剩余断言** + #(本函数五条纪律断言曾因此全部静默不跑,套件仍报 all green,是零覆盖假象)。 const EnemyTableScript := preload("res://data/schemas/enemy_table.gd") var mk_row := func(l: int, d: int, eid: String) -> Dictionary: return {"level_index": l, "node_depth": d, "enemy_id": eid, "display_name": eid + "名", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} # 正向:同坐标两行不同敌种(相邻)= 混编合法,validate 零错 var t_mix = EnemyTableScript.new() - t_mix.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear")] + t_mix.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear"), + mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(validate 的 anchor 规则:每 level 必须有) var errs: Array = t_mix.validate() Kit.check("混编行集加载通过(同坐标两行不同敌种相邻——行集恰一敌种校验退役的正向面)", errs.is_empty()) # 正向:Boss 坐标两行同构合法(P4 乙——Boss 行集校验同样放开,加载层无 Boss 单行硬闸) var t_boss = EnemyTableScript.new() - t_boss.rows = [mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear")] + t_boss.rows.assign([mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear"), + mk_row.call(2, 0, "drider")]) # 末行=depth-0 锚行 errs = t_boss.validate() Kit.check("Boss 坐标混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)", errs.is_empty()) # 负向:同坐标行不相邻 = rowset_rule 且报错定位到坐标(R6 行序纪律 + R3 错误类别可定位) var t_gap = EnemyTableScript.new() - t_gap.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear")] + t_gap.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear"), + mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(隔断关系不受影响) errs = t_gap.validate() var gap_hit := false for e in errs: @@ -218,7 +224,8 @@ func _test_rowset_discipline() -> void: Kit.check("行序相邻校验:同坐标行被隔断报 rowset_rule 且定位坐标(R6——行序即契约加载期兜底)", gap_hit) # 负向:同敌种第二行仍被 dup_key 拦(键含 enemy_id——纪律①既有通道不回归) var t_dup = EnemyTableScript.new() - t_dup.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin")] + t_dup.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin"), + mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行 errs = t_dup.validate() var dup_hit := false for e in errs: @@ -227,7 +234,7 @@ func _test_rowset_discipline() -> void: Kit.check("行集内同敌种第二行仍被 dup_key 拦(纪律①——enemy_id 唯一)", dup_hit) # 负向:域校验不回归(单行 unit_count 越域仍拦——UNIT_COUNT_MAX 单种数语义下的同款域闸) var t_dom = EnemyTableScript.new() - t_dom.rows = [mk_row.call(1, 5, "goblin")] + t_dom.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行 t_dom.rows[0]["unit_count"] = 4 errs = t_dom.validate() var dom_hit := false diff --git a/game/tests/unit/presentation/battle_narrator_test.gd b/game/tests/unit/presentation/battle_narrator_test.gd new file mode 100644 index 00000000..4aa803d2 --- /dev/null +++ b/game/tests/unit/presentation/battle_narrator_test.gd @@ -0,0 +1,44 @@ +# tests/unit/presentation/battle_narrator_test.gd — 战斗叙述直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 纯函数文案:休整/商店/终局三类叙述把数值带进去(玩家读数面由这里出)、 +# 胜负两态文案互异、未知输入有兜底不吐空串。期望值由注入内容数出来。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/battle_narrator_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const NarratorScript := preload("res://presentation/battle_narrator.gd") + + +func _init() -> void: + _test_numeric_lines_carry_values() + _test_end_text_differs_by_result() + _test_phase_text_nonempty() + quit(Kit.finish("battle_narrator_test")) + + +func _test_numeric_lines_carry_values() -> void: + var rest: String = NarratorScript.rest_line(12, 30, 40) + Kit.check("休整叙述带入回复量与当前血量(实际「%s」)" % rest, + rest.contains("12") and rest.contains("30") and rest.contains("40")) + var shop: String = NarratorScript.shop_line(77) + Kit.check("商店叙述带入 GP 数(实际「%s」)" % shop, shop.contains("77")) + Kit.check("奖励行与休整行互异(不是同一句套所有场景)", + NarratorScript.reward_line() != rest) + + +func _test_end_text_differs_by_result() -> void: + # 关卡收束(level_end=true)恒为「关卡通关!」——单场胜负文案只在非收束态出现 + var level: String = NarratorScript.battle_end_text("lose", true) + Kit.check("关卡收束态恒为「关卡通关!」(实际「%s」)" % level, level == "关卡通关!") + var win: String = NarratorScript.battle_end_text("victory", false) + var lose: String = NarratorScript.battle_end_text("lose", false) + Kit.check("单场胜负文案互异(胜「%s」/负「%s」)" % [win, lose], win != lose) + Kit.check("胜利文案非空(实际「%s」)" % win, win != "") + var unknown: String = NarratorScript.battle_end_text("something_else", false) + Kit.check("未知结果有兜底文案不吐空串(实际「%s」)" % unknown, unknown != "") + + +func _test_phase_text_nonempty() -> void: + var t: String = NarratorScript.phase_text(1, "", false, false, "") + Kit.check("相位叙述非空(实际「%s」)" % t, t != "") + var boss: String = NarratorScript.phase_text(2, "", false, true, "灰矮人石君王") + Kit.check("Boss 在场时叙述带入 Boss 名(实际「%s」)" % boss, boss.contains("灰矮人石君王")) diff --git a/game/tests/unit/presentation/hud_presenter_test.gd b/game/tests/unit/presentation/hud_presenter_test.gd new file mode 100644 index 00000000..8db3b1f0 --- /dev/null +++ b/game/tests/unit/presentation/hud_presenter_test.gd @@ -0,0 +1,47 @@ +# tests/unit/presentation/hud_presenter_test.gd — HUD 投影直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 纯函数投影:动作结果文案按拒绝原因逐类落字(玩家看到的每句提示都由这里出)、 +# 未知原因有兜底文案(不吐空串)、棋盘变异行投影随表走。期望值由注入内容数出来。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/hud_presenter_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const HudScript := preload("res://presentation/hud_presenter.gd") + + +func _init() -> void: + _test_accepted_text() + _test_reject_text_by_reason() + _test_unknown_reason_fallback() + quit(Kit.finish("hud_presenter_test")) + + +func _test_accepted_text() -> void: + var t: String = HudScript.outcome_text({"accepted": true}) + Kit.check("受理态文案固定为「执行完成。」(实际「%s」)" % t, t == "执行完成。") + + +func _test_reject_text_by_reason() -> void: + var cases := { + "not_adjacent": "不能执行:目标与小人不相邻", + "target_revealed": "不能执行:目标已开", + "flag_present": "不能插旗:此格已有旗(旗为一次性承诺——相邻时左键直接挖开)", + } + var bad: Array[String] = [] + for sub in cases: + var got: String = HudScript.outcome_text({ + "accepted": false, "reject_reason": "board_face", "sub_reason": sub, + }) + if got != String(cases[sub]): + bad.append("%s→「%s」" % [sub, got]) + Kit.check("棋盘面各拒绝原因文案逐类正确(错 %d 项 %s)" % [bad.size(), str(bad)], bad.is_empty()) + var ap: String = HudScript.outcome_text({"accepted": false, "reject_reason": "resource_face"}) + Kit.check("资源面拒绝文案为「AP 不足。」(实际「%s」)" % ap, ap == "AP 不足。") + + +func _test_unknown_reason_fallback() -> void: + var t: String = HudScript.outcome_text({"accepted": false, "reject_reason": "brand_new_reason"}) + Kit.check("未知拒绝原因兜底为「动作被拒。」而非空串(实际「%s」)" % t, t == "动作被拒。") + var sub: String = HudScript.outcome_text({ + "accepted": false, "reject_reason": "board_face", "sub_reason": "brand_new_sub", + }) + Kit.check("棋盘面未知子因兜底带前缀可诊断(实际「%s」)" % sub, sub.begins_with("棋盘面拒绝: ")) diff --git a/game/tests/unit/test_kit.gd b/game/tests/unit/test_kit.gd index c78f0221..7cbd9b4a 100644 --- a/game/tests/unit/test_kit.gd +++ b/game/tests/unit/test_kit.gd @@ -5,7 +5,7 @@ # const Kit := preload("res://tests/unit/test_kit.gd") # Kit.check("断言描述", cond) # 计数 + 即时 PASS/FAIL 行 # Kit.check_quiet(cond, "描述") # 计数 + 失败收集(finish 时统一汇总——原 _problems 风格,原参序) -# quit(Kit.finish("suite_name")) # 汇总输出 + 返回退出码(0 全绿 / 1 有败) +# quit(Kit.finish("suite_name")) # 汇总输出 + 返回退出码(0 全绿 / 1 有败 / 2 零断言) # 计数器为 static(套件 = 独立 Godot 子进程恰一份生命周期,见 run_all.gd 执行形态); # 单进程跑多套件须先 Kit.reset()。 extends RefCounted @@ -39,6 +39,12 @@ static func check_quiet(ok: bool, desc: String) -> void: #(保留原套件 PASS 摘要文案;缺省 = "all green (N checks)" 计数口径)。 # 失败路径:push_error 计数汇总 + problems 逐条 FAIL 行(print + push_error 双通道——原两风格并档)。 static func finish(suite: String, pass_line: String = "") -> int: + # 断言数下限(三硬闸之三):零断言的套件必须判红——「跑了一遍没崩」不等于测过。 + # 退出码用 2(而非 1)与「断言失败」区分:致命类套件 manifest 期望 1, + # 零断言若也返回 1 会被 expect 协议当成预期结果放过。 + if checks == 0: + push_error("[%s] 零断言——套件未执行任何断言(疑似脚本错误中断或误挂空壳),判红" % suite) + return 2 if failures > 0: push_error("[%s] %d/%d 项失败" % [suite, failures, checks]) for p in problems: diff --git a/roadmap.md b/roadmap.md index 7fa008b0..2a6d0c84 100644 --- a/roadmap.md +++ b/roadmap.md @@ -24,8 +24,8 @@ ## 当前位置(2026-10-16 刷新) - **里程碑语义(2026-10-09 立规换代,详见下方里程碑表的换代注)**:现行里程碑 = **M3「Alpha 版本的完整实现」**——用户未明令宣布完成前停留 M3,迭代只排 M3 需求;M4=Beta 版本的完整实现,一律不排期。下文里程碑表的旧编号(M-3/M-4)保留作历史序列,语义以本节为准。 -- **迭代进度**:迭代 19 五卡全部已入库(2026-10-08);**迭代 20=专长实装批 ✅ 已入库**(2026-10-11,出口闸全绿;残留人闸=实机目检五件+V20/V21/V22 人工核对半,07 在册);**迭代 21=天赋树实装批 ✅ 已入库(2026-10-12 U0 开工单会话交付,T0-T7 全绿;残留人闸=实机目检四柜台)**;**迭代 22=秘宝体系实现重构落地批 ✅ 已入库(2026-10-12 单会话交付,T1-T7 全绿;残留人闸=实机目检秘宝解锁-装配流程)**;**迭代 23=锻造工坊实装批 ✅ 已入库(2026-10-12 单会话交付,T0-T7 全绿;残留人闸=实机目检工作间形态与购买交互)**;**迭代 24=敌人表内容修批(三关)✅ 已入库(2026-10-13 /goal+iter U0 开工单会话交付——N1 (1,2) 降档 hp22/ac2·N2 毒吐盾击两行六池·N3 敌方 attack_type 轴全填 normal+敌方结算克制消费点;冒烟 949/0+run_all 25 套+重闸全绿+MC 同种子配对 40 局×both 位移在带;实现报告=design/qa/iter24-实现报告-2026-10-13.md;残留人闸=实机目检三观测〔07 ㉓〕;代拍板追认单+GDD 反哺点五条待用户;**本迭代位仍预留后续追加卡——用户将把其他需求陆续编入本迭代**);;**迭代 25=UI 全局主题批 ✅ 已入库**(2026-10-13 用户令编入,/goal+iter U0 单会话交付:全局主题资源 main_theme.tres 挂载+两处局部做法退役+冒烟 V1-V6 断言 38 条,冒烟 987/0+重闸全绿;实现报告=design/qa/iter25-实现报告-2026-10-13.md;残留人闸=实机目检全屏巡检〔07 ㉔〕);**迭代 26=UI 交互与观感批已编入待开工**(2026-10-14 用户令;需求卡片 v1.2 零待裁点;开工门=U0——用户新会话 /iter 26 发令;本迭代位预留后续追加卡,同迭代 24 先例);**迭代 27=代码架构技术债修复批 ✅ 已入库**(2026-10-14 /goal+iter U0 开工单会话交付:三屏场景化+会话物化+五段 Segment+RunLedger+ModalFlow+类型闸+卫生件五波全落;逐位一致对拍三变体五跑全同;实现报告=design/qa/iter27-实现报告-2026-10-14.md;残留人闸=实机目检五件〔07 ㉖〕;代拍板追认单十五项+GDD 反哺点两条待用户);**迭代 28=启动装配与生命周期验收补全批 ✅ 已入库**(2026-10-15 用户令编入并预批连做全批,单会话交付:T1 装配语义合同 `LAYER_CONTRACT` 单点化+冒险屏先挂/菜单屏后挂的顺序修复+V1a-d 断言重写/T2 `_smoke_boot_chain` boot 链路十断言/T3 `_smoke_lifecycle_matrix` 九阶段×五引用面/T4 渲染面三件(shot_probe 两自检+shot_checker 墨色判据+启动态菜单 golden)/T5 变异验证六次注入全见红并精确还原/T6 规范三处落文;冒烟 **1086/0→1125/0**(+39=新增 42−退役裸下标断言 3);实现报告=design/qa/iter28-实现报告-2026-10-15.md;残留人闸=实机目检启动态菜单〔07 ㉗〕;代拍板追认单+GDD 反哺点待用户裁决);**迭代 29=测试分层与门禁语义修正批(2026-10-16 用户令编入·待开工)**:需求卡片=`design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md`(v1.4,T0–T8≈5.5–7d,开工门=U0〔Q3–Q7 裁点+卡片 v2.0 重核+`/iter` 发令〕);**迭代 30=UI 呈现三卡批(徽章→样张→观感,迭代内串行)已编入·待开工**(2026-10-16 用户令编入——三卡待裁项同日用户令「按推荐方案」全部代拍板留痕、卡片均 v1.2,详见迭代 30 表行;开工门=U0 `/iter` 发令);**迭代 31+ 位空·无批次**(原 M4 数值批已废——2026-10-16 用户删卡令「它就不是一个应该存在的东西」,禁止再以该名义立批/规划/引用已删卡;四项数值欠账归 06 在册、重新归属待用户裁)。 -- **近期主战场**:迭代 20/21/22/23/24/25/26 残留人闸(实机目检六批,07 在册);**下一前沿=迭代 29 测试分层与门禁语义修正批**(2026-10-16 用户令编入·待开工——开工门=U0〔Q3–Q7 裁点+卡片 v2.0 重核+`/iter` 发令〕;其后**迭代 30=UI 呈现三卡批**同日用户令编入·待开工〔三卡裁点已留痕,见迭代 30 表行〕;各批 provisional 终值的统一标定义务随 M4 数值批废除归 06 在册、重新归属待裁;迭代 20~28 残留人闸八批 07 在册)。 +- **迭代进度**:迭代 19 五卡全部已入库(2026-10-08);**迭代 20=专长实装批 ✅ 已入库**(2026-10-11,出口闸全绿;残留人闸=实机目检五件+V20/V21/V22 人工核对半,07 在册);**迭代 21=天赋树实装批 ✅ 已入库(2026-10-12 U0 开工单会话交付,T0-T7 全绿;残留人闸=实机目检四柜台)**;**迭代 22=秘宝体系实现重构落地批 ✅ 已入库(2026-10-12 单会话交付,T1-T7 全绿;残留人闸=实机目检秘宝解锁-装配流程)**;**迭代 23=锻造工坊实装批 ✅ 已入库(2026-10-12 单会话交付,T0-T7 全绿;残留人闸=实机目检工作间形态与购买交互)**;**迭代 24=敌人表内容修批(三关)✅ 已入库(2026-10-13 /goal+iter U0 开工单会话交付——N1 (1,2) 降档 hp22/ac2·N2 毒吐盾击两行六池·N3 敌方 attack_type 轴全填 normal+敌方结算克制消费点;冒烟 949/0+run_all 25 套+重闸全绿+MC 同种子配对 40 局×both 位移在带;实现报告=design/qa/iter24-实现报告-2026-10-13.md;残留人闸=实机目检三观测〔07 ㉓〕;代拍板追认单+GDD 反哺点五条待用户;**本迭代位仍预留后续追加卡——用户将把其他需求陆续编入本迭代**);;**迭代 25=UI 全局主题批 ✅ 已入库**(2026-10-13 用户令编入,/goal+iter U0 单会话交付:全局主题资源 main_theme.tres 挂载+两处局部做法退役+冒烟 V1-V6 断言 38 条,冒烟 987/0+重闸全绿;实现报告=design/qa/iter25-实现报告-2026-10-13.md;残留人闸=实机目检全屏巡检〔07 ㉔〕);**迭代 26=UI 交互与观感批已编入待开工**(2026-10-14 用户令;需求卡片 v1.2 零待裁点;开工门=U0——用户新会话 /iter 26 发令;本迭代位预留后续追加卡,同迭代 24 先例);**迭代 27=代码架构技术债修复批 ✅ 已入库**(2026-10-14 /goal+iter U0 开工单会话交付:三屏场景化+会话物化+五段 Segment+RunLedger+ModalFlow+类型闸+卫生件五波全落;逐位一致对拍三变体五跑全同;实现报告=design/qa/iter27-实现报告-2026-10-14.md;残留人闸=实机目检五件〔07 ㉖〕;代拍板追认单十五项+GDD 反哺点两条待用户);**迭代 28=启动装配与生命周期验收补全批 ✅ 已入库**(2026-10-15 用户令编入并预批连做全批,单会话交付:T1 装配语义合同 `LAYER_CONTRACT` 单点化+冒险屏先挂/菜单屏后挂的顺序修复+V1a-d 断言重写/T2 `_smoke_boot_chain` boot 链路十断言/T3 `_smoke_lifecycle_matrix` 九阶段×五引用面/T4 渲染面三件(shot_probe 两自检+shot_checker 墨色判据+启动态菜单 golden)/T5 变异验证六次注入全见红并精确还原/T6 规范三处落文;冒烟 **1086/0→1125/0**(+39=新增 42−退役裸下标断言 3);实现报告=design/qa/iter28-实现报告-2026-10-15.md;残留人闸=实机目检启动态菜单〔07 ㉗〕;代拍板追认单+GDD 反哺点待用户裁决);**迭代 29=测试分层与门禁语义修正批 ✅ 已入库**(2026-10-16 /iter U0 开工单会话交付:T1 三硬闸〔退出码/脚本错误扫描/断言数下限,三反例改前判绿改后判红〕/T2 术语订正+CI 残留 8 处清除/T3 `tests/integration/` 建承载+parity 收编为重闸 11 号条件闸件/T4 `screen_flow_probe` 入轻闸/T5 新增六套单测〔27→33 套〕/T6 S3 路径修正+零命中自检+补 S5/T7 游离脚本台账+golden 判有效/T8 架构事实源三张表回写+06 登记遗留七条;冒烟 1127/0 持平、重闸全绿存量红零新增;实现报告=design/qa/iter29-实现报告-2026-10-16.md;**无用户目检项**;代拍板追认单十二项+GDD 反哺点三条待用户裁决;**遗留:11 号闸 parity 对拍判为触面,需用户批准才发起**);**迭代 30=UI 呈现三卡批(徽章→样张→观感,迭代内串行)已编入·待开工**(2026-10-16 用户令编入——三卡待裁项同日用户令「按推荐方案」全部代拍板留痕、卡片均 v1.2,详见迭代 30 表行;开工门=U0 `/iter` 发令);**迭代 31+ 位空·无批次**(原 M4 数值批已废——2026-10-16 用户删卡令「它就不是一个应该存在的东西」,禁止再以该名义立批/规划/引用已删卡;四项数值欠账归 06 在册、重新归属待用户裁)。 +- **近期主战场**:迭代 20/21/22/23/24/25/26 残留人闸(实机目检六批,07 在册);**迭代 29 已入库;下一前沿=迭代 30 UI 呈现三卡批**(迭代 29 收口遗留=11 号闸 parity 对拍待批准发起+GDD 反哺三条待裁;其后**迭代 30=UI 呈现三卡批**同日用户令编入·待开工〔三卡裁点已留痕,见迭代 30 表行〕;各批 provisional 终值的统一标定义务随 M4 数值批废除归 06 在册、重新归属待裁;迭代 20~28 残留人闸八批 07 在册)。 - **实时状态**:以根目录 [active.md](active.md) 顶部「当前状态快照」节为准(`stage.txt` 已定性为旧阶段开关,仅存档)。 ## 主线路线(里程碑 × 迭代序列) @@ -85,7 +85,7 @@ | 迭代 26 | **①UI 交互与观感批(2026-10-14 用户令编入;UI 美化三层诊断 · 第一层第二/三条+第三层四件;本迭代位预留后续追加卡,同迭代 24 先例)**:需求卡片=`design/research/需求卡片-UI交互与观感批-2026-10-13.md`(**v1.2**——grilling 两轮八裁全落、零待裁点、卡片成立)。六项:**T1 按钮五态矩阵**(悬停=乙案 modulate 提亮〔与 primary_button 现行 `CANDLE.lightened(0.06)` 同纪律〕/禁用=本族底+α0.45 幽灵化/焦点=INK 2px 内缩 1px/危险钮补本族 pressed;走逐实例 override,迭代 25 两条主题红线断言维持不换代)· **T2 全局纹理过滤**(`project.godot` `default_texture_filter=0`+vignette/gradient_base/beams 显式 LINEAR 白名单+翻转面全枚举)· **T3 <4.5:1 换墨**(站桩顺位/护甲徽标换同族亮档+RAP 行 STONE_DEEP,机检断言锚定)· **T4 光柱连续 alpha 渐变**(0→0.05→0 横向+纵向衰减)· **T5 元素细节余②③**(BCard 图标槽明度差降档/商店卡片区垂直居中/营地四栏等高)· **T5b 诊断转储钮移调试浮窗**(`dump_diagnosis` 功能保留)。🔴 契约 5 条(主题层零新增词条/零新美术素材/棋盘零触碰/对比度 ≥4.5:1/旧实现同批清理);验证闸 V1-V7;代拍板白名单八项;反哺点四条;**渲染闸 golden 面处置**=T6 第 4 步(徽标换墨落 board golden 裁剪区:闸红→核热区限预期面→显式重制基线+呈报→复闸,超预期=L3)。**范围订正**(grilling 实查,iter25 滑轨条同款先例):②垫块仅三选一 BCard(商店卡实为纯文字行卡)/③仅商店+营地(三选一已居中)/危险钮「无按下态」订正为「在但皮肤错配」。估时 T0-T6≈2–2.5d | **✅ 已全批入库(2026-10-14 /goal+iter U0 开工单会话交付,worktree g-plan2-iter26-ui 分支 iter26-ui-20261014 七提交)**——T0 基线+枚举(含卡片 §2.2 枚举漏项勘误)/T1 按钮五态(悬停 modulate 提亮/禁用 α0.45 幽灵化/焦点 INK 2px 内缩 1px/危险钮本族按压态)/T2 全局 Nearest+平滑渐变件 LINEAR 白名单/T3 三处换墨(GOLD_BRIGHT/HERO_BLUE_BRIGHT/STONE_DEEP,6.47/5.88/5.98:1)/T4 光柱连续 alpha/T5 图标槽柔和档+商店居中+营地四栏等高/T5b 诊断钮迁调试浮窗;冒烟 987→1055/0(+68 断言)+run_all 25 套+probe click/full+render_gate golden 0.00%(登记即结未重制)+doc 三闸/熵增对基线零新增;MC 零跑(零数值零行为=纯呈现);实现报告=design/qa/iter26-实现报告-2026-10-14.md;残留人闸=实机目检五项(07 ㉕);待用户=代拍板追认单十三项+GDD 反哺点四条 | T0-T6≈2–2.5d(实际单会话) | | 迭代 27 | **①代码架构技术债修复批(2026-10-14 用户令编入;来源=[代码架构与实现质量审计报告-2026-10-14](design/research/代码架构与实现质量审计报告-2026-10-14.md),双席审计基线 97de02c,已逐条复核)**:需求卡片=`design/research/需求卡片-代码架构技术债修复批-2026-10-14.md`(**v1.2——三点全裁零待裁〔2026-10-14 用户三裁全按推荐案:①第三个 autoload/②单类持四账/③审计五段〕,卡片成立**)。五波:· **波一 场景拆分+局生命周期物化(≈4-5d)**〔P0-1/P0-2:屏容器 .tscn 切分=开始菜单根场景+营地场景+冒险场景〔内含 level_map/battle 两容器〕,视图代码原样搬、**40+ views 不转 .tscn**;SaveManager/MetaProgression 升格跨场景持有者;「一局」物化为可整体 `queue_free()` 的节点,`main.gd:647-657`/`:555-559` 手工清单消亡;13 个 smoke 探针挂载假设同批迁移〕· **波二 `_settle` 拆段(≈1.5d)**〔P1-1:五段 Segment 类 grant/aura/combat/decay/verdict,24 处 kind 分派随段归位,姊妹病灶 `damage_controller.gd:272` 148 行同批拆〕· **波三 账本收口(≈1.5d)**〔P1-4 共享四账 Ledger 类化+P1-2 刷新双账删 main 副本+P1-3 summary 增 `gp_final` 键、删 `gp_shadow_ledger.gd`〕· **波四 组合根可测化抽取+模态协议内聚(≈1.5-2d)**〔P1-5/P2-7;**须在波一落地后重估抽取面再动**〕· **波五 类型闸收尾+卫生件包(≈3d)**〔P1-6 拉闸+存量 73 处收口,**须在其他重构全部落地后**;P2 七组顺手带〕。🔴 契约 6 条(**零行为变更·重构前后同种子完整局输出逐位一致**/只拆不重写/views 不转 .tscn/探针契约零破坏/信号纪律不变/执行顺序锁+渲染零漂移);**核心验收=逐位一致对拍**(RngService 单盐流在役);验证闸 V1-V9;代拍板白名单九项;反馈点两条(architecture/code-conventions)。 | **✅ 已全批入库(2026-10-14 /goal+iter U0 开工单会话交付,worktree g-plan2-iter27-techdebt 分支 iter27-techdebt-20261014 七提交)**——波一 三屏场景化+run_scope 会话物化+双 autoload(7a0d57d)/波二 _settle 五段 Segment+英雄攻击拆步(26301ba)/波三 RunLedger 单类四账+刷新双账删副本+gp_final 删影子账(6fd7d78)/波四 ModalFlow+互斥域 tags+双 context(8b9e556)/波五a 类型闸 368 参数化+审计器+warning 配置(b605022)/波五b 卫生件 13 死代码+FeatsEngine 退役+Callable 换装+状态机 enum 化+ConfigDB 归根+resolver 解耦(T6 笔)/T7 文档同步(本笔)。**闸**:逐位一致对拍三变体五跑全同(V1 核心验收)+冒烟 1055→1081/0(+V2 屏壳 11+V3 两连局 11+V4 段类 16+V6 模态 19+V5 快照 3+V7 2 断言)+run_all 25→27 套+probe click+render golden 0.00%+static_scan S1/S2/S4/S9 全绿(历史 cd 静默空转顺修);MC 对拍=parity_anchor 三变体×40 局×RUNSUM+JSONL 双面(工具入库 tools/parity_anchor.py);实现报告=design/qa/iter27-实现报告-2026-10-14.md(代拍板追认单十五项+GDD 反哺点两条);残留人闸=实机目检五件(07 ㉖);审计报告本体按退役规则 git rm | T0-T7≈12-14d(实际单会话) | | 迭代 28 | **①启动装配与生命周期验收补全批(2026-10-15 用户令编入)**:需求卡片=design/research/需求卡片-启动装配与生命周期验收补全批-2026-10-15.md(v1.0——零待裁点,用户预批连做全批)。六任务:T1 装配语义合同单点化+装配顺序修复+V2 断言重写/T2 boot 链路时序后置条件(_smoke_boot_chain 六断言)/T3 生命周期存活性矩阵(_smoke_lifecycle_matrix 十阶段×五引用面)/T4 渲染面内容断言(shot_probe 两自检+shot_checker 墨色占比+启动态菜单 golden)/T5 变异验证(横切随批)/T6 规范落文+文档同步闸+收口;修复坏事实三件(main.gd:207-210 装配序错致 F5 见战斗界面、smoke_all.gd:176 V2 断言保护 bug、菜单截图无内容断言)。🔴 契约 7 条(零玩法行为变更/合同单点化/断言禁裸下标/测试先行/变异验证门槛/只加不重构/闸机轮次纪律) | ✅ 已入库(2026-10-15 单会话交付:T0-T6 全绿;冒烟 1125/0、run_all 全套、probe_gate --click/--full、render_gate 四闸全绿;变异验证六次注入全见红;实现报告=design/qa/iter28-实现报告-2026-10-15.md;残留人闸=实机目检启动态菜单〔07 ㉗〕) | 实耗≈1 会话(原估 T0-T6≈5.25d) | -| 迭代 29 | **①测试分层与门禁语义修正批(2026-10-16 用户令编入)**:把验收语义从「跑一遍没崩=冒烟通过」扭转为「**三层完备+判定可靠**」——兑现用户三条诉求(①最单元的逻辑代码有单元测试 ②整体模块有接口测试 ③整体系统有 UI 方面与场景切换相关的测试)且验收不止于冒烟。四件事:**①本地闸三硬闸**(退出码为唯一权威判据·PASS 串降级为日志;`SCRIPT ERROR`/`Parse Error` 扫描命中即红;断言数为零即红——`probe_gate.sh` 四探针判定改退出码优先、`test_kit.finish` 零断言判红、`run_all` 汇总校验实跑套件数,全部反例实证留档);**②L2 接口层从零建承载**(建 `game/tests/integration/`+`parity_anchor` 收编入闸表+存档⇄RNG 快照往返首个集成用例);**③L3 屏壳切换探针**(新建 `screen_flow_probe`:六步主流程+三屏壳 `visible` 互斥+放弃回退路径,入轻闸);**④L1 补洞+术语订正+状态记录清理**(4 个 core 零指名模块+呈现层风险前三补单测;「冒烟」唯一保留语义=秒级存活检查;15 个游离脚本逐个定性、golden 基线时效判定、补 S5 扫描规则)。🔴 契约 7 条(零玩法行为变更/三硬闸不可绕过/现有断言面只增不减/不引入第三方测试框架/架构事实唯一源=architecture.md/探针契约零破坏/测试执行两入口铁规);验证闸 V1–V11;代拍板白名单 10 项;**无用户目检项(纯基建)**。需求卡片=`design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md`(**v1.4**——v1.1 CI 从未通电/v1.2 CI 全删后 T1 主体改本地闸/v1.3 排期定迭代 29+§0 串行裁决(iter28 入库后开工)/v1.4 随决策文档体系废除改契约 5 口径;T0–T8≈5.5–7d)。**⚠ 卡片事实基线待 v2.0 重核**:v1.4 冻结于 iter28 入库前(冒烟 1086/0·run_all 27 套·探针 11),master 当期=冒烟 1125/0+`tools/run_all_gates.sh` 重闸编排另立(轻闸=`run_all_gates.sh --quick`)+architecture.md「测试与质量域」节已成文——U0 须先重核升版再开工 | 迭代 28 出口(✅ 已满足——2026-10-15 已入库)+**U0 开工门**:Q3–Q7 五项裁点全落+卡片 v2.0 重核+用户 `/iter` 发令(Q1 已闭环·Q2 已作废) | T0–T8≈5.5–7d(软盒 7d;天然切分点 T1 末/T4 末,超盒拆两批) | +| 迭代 29 | **①测试分层与门禁语义修正批(2026-10-16 用户令编入)**:把验收语义从「跑一遍没崩=冒烟通过」扭转为「**三层完备+判定可靠**」——兑现用户三条诉求(①最单元的逻辑代码有单元测试 ②整体模块有接口测试 ③整体系统有 UI 方面与场景切换相关的测试)且验收不止于冒烟。四件事:**①本地闸三硬闸**(退出码为唯一权威判据·PASS 串降级为日志;`SCRIPT ERROR`/`Parse Error` 扫描命中即红;断言数为零即红——`probe_gate.sh` 四探针判定改退出码优先、`test_kit.finish` 零断言判红、`run_all` 汇总校验实跑套件数,全部反例实证留档);**②L2 接口层从零建承载**(建 `game/tests/integration/`+`parity_anchor` 收编入闸表+存档⇄RNG 快照往返首个集成用例);**③L3 屏壳切换探针**(新建 `screen_flow_probe`:六步主流程+三屏壳 `visible` 互斥+放弃回退路径,入轻闸);**④L1 补洞+术语订正+状态记录清理**(4 个 core 零指名模块+呈现层风险前三补单测;「冒烟」唯一保留语义=秒级存活检查;15 个游离脚本逐个定性、golden 基线时效判定、补 S5 扫描规则)。🔴 契约 7 条(零玩法行为变更/三硬闸不可绕过/现有断言面只增不减/不引入第三方测试框架/架构事实唯一源=architecture.md/探针契约零破坏/测试执行两入口铁规);验证闸 V1–V11;代拍板白名单 10 项;**无用户目检项(纯基建)**。需求卡片=`design/research/需求卡片-测试分层与门禁语义修正批-2026-10-15.md`(**v2.0**——三轮复查定稿、零待裁点;§2 事实面改指针归 architecture.md「测试与质量域」节,T2 吸收 CI 残留 8 处,T6 收 S3 路径修正+零命中自检,T5 补洞选面换 board_generator;T0–T8≈6.0–7.5d) | ✅ 已入库(2026-10-16 /iter U0 发令;worktree `g-plan2-iter29-testlayers`,分支 `iter29-testlayers-20261016`;重闸全绿、存量红零新增;**遗留=11 号闸 parity 对拍待用户批准发起**) | 实耗≈1 会话(原估 T0–T8≈6.0–7.5d) | | 迭代 30 | **①UI 呈现三卡批(2026-10-16 用户令编入——三卡待裁项同日用户令「按推荐方案」全部代拍板留痕,卡片均 v1.2;迭代内串行 ①→②→③,禁三线并行,依据=06/08/07/art-bible/roadmap/active 汇聚文件互踩〔grilling 审查记录 D3〕)**:**①未开格类型徽章批**——类型微光实装:英雄周界 8 格内未开格、组粒度五组色相(色相系既裁=art-bible 样张 v2 定稿,本批只补从未定义的组间明度档 provisional 值、复核转正义务移交②)、渲染路线裁丙=每格 CanvasItem shader 接触带柔光为主+程序绘制兜底(T0 原型帧先行件前置;视觉调优走「出帧→目检→改参」迭代,不承诺轮数)、开关裁丙=默认开+组合根布尔+调试浮窗可切(off 态逐位一致断言承接基线保护)、静态态(§2.1 既裁合规)、两套色带不并入本批(统一另登 06 条·批次出口写入文档)。卡片=`design/research/需求卡片-未开格类型徽章批-2026-10-16.md` → **②战斗一屏样张批**——裁丙「样张即设计目标图」:本批仅战斗屏(Q2 甲,余三屏登 06 排队)、引擎实拍+shader 为主路线(Q3 乙,半天试验件前置、证伪预设退甲案纯代码管线)、字重按目标效果示意(Q4 丙,字体补完批按样张补真件)、双口径两闸分跑(Q6 乙)、死指涉改指在盘最近稿注「指代稿」(Q5 甲);交付含目标规格清单(尺寸档/色板/明度档逐件成文,反向给素材批)+徽章明度档复核转正。卡片=`design/research/需求卡片-战斗一屏样张批-2026-10-16.md`(≈5–6d)→ **③第二/三关棋盘观感批**——L2/L3 同批(Q3 甲);T1 定性采样+归因报告过 06 既裁「素材轴/UI 轴」定性人闸(含 T1b 新建舞台底采样件);素材轴默认甲案 recolor 换色候选样张先行,甲/乙(远程生图重产)裁点挂 T1 出口呈报硬步骤;若裁乙,瓦片规格随②目标规格清单、与徽章明度档同基线防互踩。卡片=`design/research/需求卡片-二三关棋盘观感批-2026-10-16.md`(5–7d)。验收共性=机检断言+用户目检(真人检测按 CLAUDE.md 既裁登记不阻塞、否决即回补);08「五 · 表现」对应三行出口改写 | 迭代 29 完成(汇聚文件串行)+**U0 开工门**:用户 `/iter` 发令(三卡裁点已全部留痕·无待裁残留) | 三批串行合计 ≈11–14d——超软盒 7d,按批次分会话逐个 U0 发令(各卡内部切分点见卡),禁单会话连做跨卡 | | 迭代 31+ | **(位空·无批次——原「M4 数值批」已废)**:2026-10-16 用户令删除需求卡片(原话「它就不是一个应该存在的东西」)⇒ **禁止再以「M4 数值批」名义立批、再规划、再引用已删卡片**。四件数值欠账本身仍在册——①削弱档位终值 ②调参批数值点评(06 条目现行五条)③钥石产出重标+通关率带重扫(迭代 10 奖励重构联动欠账)④元进度定价剩余面(forge 定价·Boss 两档终值)——连同各批 provisional 终值标定义务,**一律归 `design/gdd/06-待办清单.md` 跟踪,重新归属待用户裁**;读数复算义务(迭代 14 两件后置)仍挂 07,damage_probe A1 既有红灯在册。M4=Beta 阶段口径不变(M3 未由用户明令关闭前不排期);本迭代位留空待用户令编入 | 用户发令编入(未排期) | —— | diff --git a/tools/error_scan.py b/tools/error_scan.py new file mode 100644 index 00000000..1cb5cff0 --- /dev/null +++ b/tools/error_scan.py @@ -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()) diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index 56fae644..7b69c3b4 100644 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -10,6 +10,8 @@ # 增删档位只改表内一行;用 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 逐像素)—— @@ -33,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' @@ -55,52 +90,38 @@ 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 -if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — layout_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 -elif ! grep -q "PASS" "$TMP/lay.log"; then - echo "[probe_gate] FAIL — layout_probe 未见 PASS(exit=$st)"; _rc=1 -fi +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/ @@ -110,41 +131,25 @@ if [ "$WITH_FULL" -eq 1 ]; then 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 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/flp.log"; then - echo "[probe_gate] FAIL — ui_probe 未见 PASS(exit=$st)"; _rc=1 - fi + 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 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_tune_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/flt.log"; then - echo "[probe_gate] FAIL — ui_tune_probe 未见 PASS(exit=$st)"; _rc=1 - fi + 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 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — t17_defer_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/fld.log"; then - echo "[probe_gate] FAIL — t17_defer_probe 未见 PASS(exit=$st)"; _rc=1 - fi + 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 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — fx_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/flx.log"; then - echo "[probe_gate] FAIL — fx_probe 未见 PASS(exit=$st)"; _rc=1 - fi + verdict "fx_probe" "$TMP/flx.log" "$st" || _rc=1 fi echo "======== 残留检查(批末闸) ========" diff --git a/tools/run_all_gates.sh b/tools/run_all_gates.sh index 83b3d30c..f1d41170 100644 --- a/tools/run_all_gates.sh +++ b/tools/run_all_gates.sh @@ -87,6 +87,12 @@ gate_unit() { # run_all 是 --script 模式,退出码为唯一判据 ( 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 @@ -113,6 +119,45 @@ 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" +# ── 6c. 回放确定性对拍(条件闸件——迭代 29 T3 收编)── +# 触面判定:本批 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 + # ── 7. 静态扫描域 + 熵增闸 ── if [ $QUICK -eq 0 ]; then gate "09 静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh" @@ -148,7 +193,9 @@ def head(): def fingerprint(exit_code, log): """红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声。 绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。""" - if exit_code in ("0", "SKIP"): + # 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"