diff --git a/AGENTS.md b/AGENTS.md index 4ba81ae1..742a2cf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,8 @@ ## 运行与验证(工作目录 = 含 project.godot 的切片目录) -- **交付前验收 = 跑 `tools/run_all_gates.sh`,按本批改动域选档(`--code` 只跑代码域/`--docs` 只跑文档域/不带参数=两域全量),所选域全绿才合回 master**——它是全部验证闸的唯一入口与唯一清单,**域划分也只写在它这一个地方**,闸件增减与分域调整都只改它,文档禁另列清单。一次改动同批混触 `game/` 与文档面时跑全量档。跑完写回执 `game/tests/.gates/receipt.json`(含 `domain` 字段),合回时由 worktree-guard 按 diff 路径推出该验哪个域再校验回执(五况拦截细则见 process-conventions §3.10) -- 单件直跑(调试期最窄确认用,**不构成验收**):系统级断言面 `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` +- **交付前验收 = 跑 `tools/run_all_gates.sh`,按本批改动域选档(`--code` 只跑代码域/`--docs` 只跑文档域/不带参数=两域全量),所选域全绿才合回 master**——它是全部验证闸的唯一入口与唯一清单,**域划分也只写在它这一个地方**,闸件增减与分域调整都只改它,文档禁另列清单。一次改动同批混触 `game/` 与文档面时跑全量档。跑完写回执 `game/tests/.gates/receipt.json`(含 `domain` 字段),合回时由 worktree-guard 按 diff 路径推出该验哪个域再校验回执(五况拦截细则见 process-conventions §3.9) +- 单件直跑(调试期最窄确认用,**不构成验收**):冒烟测试 `python tools/run_smoke.py`(`game/tests/smoke_boot.tscn` 极小启动场景)· 系统测试 `python tools/run_system_test.py`(过渡期宿主 `game/smoke/smoke_all.gd`)· 场景探针 `…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`,报告与原始数据落 `sim-data/`) - 熵增闸(新类别 = 同批修订 Project Layout + 白名单):`python tools/repo_structure_audit.py` diff --git a/design/engineering/architecture/architecture.md b/design/engineering/architecture/architecture.md index 14c641cc..6423d481 100644 --- a/design/engineering/architecture/architecture.md +++ b/design/engineering/architecture/architecture.md @@ -8,7 +8,7 @@ - 评审模式:solo(`review-mode.txt`,2026-09-12 用户拍板——TD/LP 门禁按 solo 跳过,用户授权架构产出自主推进、迭代期回调) - 覆盖 GDD:现行全集——`design/gdd/` 顶层 00–08 共 9 件 + `systems/` 五域 25 件(排雷循环 4 / 战斗循环 5 / 冒险循环 5 / 跨局循环 5 / 表现与支撑 6)+ `ux/` 8 件;原英文命名 12 份系统 GDD 已全部迁入中文域目录,原「未设计」预留(A5 扫雷秘宝 / C1–C4 / B5 / D3–D6)均已立项成文 - 事实源地位:本文是架构事实的唯一来源——分层/所有权/数据流/API 边界/测试与质量域均以本文为准;历史决策沿革查 git commit log,不另设决策文档体系 -- 实现实证:`game/`(2026-09-10 起开发,**现为完整游戏开发版本,已非垂直切片**;迭代 29 已入库合并——冒烟 1127/0、单元 33 套(含 1 集成)、重闸按改动域分跑在役)——本文档的分层/所有权/数据流均经实现验证,非纯纸面设计 +- 实现实证:`game/`(2026-09-10 起开发,**现为完整游戏开发版本,已非垂直切片**;测试层级=冒烟/单元/集成/系统四级+UI 探针与渲染 golden(2026-10-22 用户裁,测试术语与层级重构批),闸机按改动域分跑在役)——本文档的分层/所有权/数据流均经实现验证,非纯纸面设计 - 技术总监签核:2026-09-12 — 有条件通过(原条件「决策记录须于正式工程骨架前定稿」已满足:骨架已成、沿革在 git 历史可查;`.claude/docs/director-gates.md` 缺失已登记技术债) - 主程序可行性:跳过 — solo 模式 @@ -185,9 +185,10 @@ | 模块 | 载体 | 独占职责 | 对外暴露 | 判定口径 | |---|---|---|---|---| -| **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**(单元套件+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` | +| **run_smoke**(冒烟测试运行器) | `tools/run_smoke.py` | 冒烟测试总入口与判定权:headless 以项目运行目标拉起 `res://tests/smoke_boot.tscn`(极小启动场景——autoload 在位、配置表全载、主屏实例化,零逻辑判定)+ 三闸判定 | 进程退出码(0=PASS / 1=FAIL) | 三闸齐备、判定无洞:①退出码 = 0;②摘要行「[smoke] 摘要:N 项检查,0 项失败」在案且失败数为 0;③输出扫 `SCRIPT ERROR` / `Parse Error` 标记行。外部 wall-clock 超时在位(超时 = FAIL) | +| **run_system_test**(系统测试运行器) | `tools/run_system_test.py` | 系统测试总入口:headless 跑真实玩法路径断言面(过渡期宿主=`res://smoke/smoke_all.gd`,断言迁移批后=`res://tests/system_test.gd`)+ 三闸判定(口径同 run_smoke,摘要行兼容两种宿主格式) | 进程退出码(0=PASS / 1=FAIL) | 三闸齐备(同 run_smoke:退出码/摘要失败数/脚本错误扫描;wall-clock 超时在位) | +| **run_all**(单元与集成测试) | `game/tests/run_all.gd` | 40 套统一入口(39 单元+1 集成,2026-10-22 实数):套件 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**(屏壳切换),`--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 判定(见下表) | @@ -195,7 +196,7 @@ | 域 | 闸 | 本批改动落在这些路径即属该域 | |---|---|---| -| **代码域** | run_smoke、run_all、probe_gate `--click`、probe_gate `--full`、render_gate、static_scan、回放确定性对拍(条件件) | **唯一触发=`game/`**(游戏工程代码、测试、探针、场景、数据表)。2026-10-18 用户裁「跨领域执法禁绝」(提交 `7bdf53e3`)后,原 `tools/`、`artwork/` 两前缀不再把改动拉进代码域 | +| **代码域** | run_smoke、run_system_test、run_all、probe_gate `--click`、probe_gate `--full`、render_gate、static_scan、回放确定性对拍(条件件) | **唯一触发=`game/`**(游戏工程代码、测试、探针、场景、数据表)。2026-10-18 用户裁「跨领域执法禁绝」(提交 `7bdf53e3`)后,原 `tools/`、`artwork/` 两前缀不再把改动拉进代码域 | | **文档域** | gdd_lint、doc_link_audit、doc_term_audit、流水线闸 | 一切非 `game/` 的路径:`design/`、根规则文件(`AGENTS.md`/`roadmap.md`/`待办清单.md`/`体验验证清单.md`)、`tools/` 工具脚本、`.zcode/` 配置、`sim-data/`、`reports/` | | **两域都含** | repo_structure_audit(熵增闸:顶层目录/根文件/图像白名单/退役路径/design 子目录五查——文档批新增或挪动 `.md` 同样会绊它;纯文本扫描秒级) | — | @@ -203,11 +204,11 @@ ### 三条判定硬闸现行状态 -| 硬闸 | run_smoke | run_all | probe_gate | render_gate | static_scan | -|---|---|---|---|---|---| -| **退出码优先** | 已落实(闸①) | 已落实(套件 manifest expect 协议) | 已落实(`verdict` 函数逐段判退出码,PASS 串不作判据) | 已落实(段级退出码 + 取证双检) | 已落实(fail 聚合退出) | -| **SCRIPT ERROR 扫描** | 已落实(闸③,外部扫描——GDScript 无脚本错误陷阱 API,闸设在外部包装) | 已落实(入口内扫子套件输出;子进程的 stdout/stderr 须先落盘——`OS.execute` 管道在本机恒空) | 已落实(逐探针段 `tools/error_scan.py`,标记与 run_smoke 同源) | **缺失**(本批未收——判定主体是 golden 容差与取证存在性;登记待办清单) | 不适用(静态域无运行时错误面) | -| **断言数下限** | 无独立下限(闸②摘要行必须在案,防「跑一半仍 PASS」;脚本错误吞断言路径由闸③兜住) | 已落实(`Kit.finish` 零断言返回专用退出码 2——与「断言失败=1」区分,防致命类套件的 expect 协议放过) | 已落实(退出码 0 但无该探针签名标记亦判红——防静默退出) | 不适用(golden 容差 + 取证存在性判定) | 不适用(逐规则命中判定;另有扫描面为空即判红的零命中自检) | +| 硬闸 | run_smoke | run_system_test | run_all | probe_gate | render_gate | static_scan | +|---|---|---|---|---|---|---| +| **退出码优先** | 已落实(闸①) | 已落实(闸①,口径同 run_smoke) | 已落实(套件 manifest expect 协议) | 已落实(`verdict` 函数逐段判退出码,PASS 串不作判据) | 已落实(段级退出码 + 取证双检) | 已落实(fail 聚合退出) | +| **SCRIPT ERROR 扫描** | 已落实(闸③,外部扫描——GDScript 无脚本错误陷阱 API,闸设在外部包装) | 已落实(闸③,标记清单与 run_smoke 同源) | 已落实(入口内扫子套件输出;子进程的 stdout/stderr 须先落盘——`OS.execute` 管道在本机恒空) | 已落实(逐探针段 `tools/error_scan.py`,标记与 run_smoke 同源) | **缺失**(本批未收——判定主体是 golden 容差与取证存在性;登记待办清单) | 不适用(静态域无运行时错误面) | +| **断言数下限** | 无独立下限(闸②摘要行必须在案,防「跑一半仍 PASS」;脚本错误吞断言路径由闸③兜住) | 同 run_smoke(过渡期宿主摘要行必须在案) | 已落实(`Kit.finish` 零断言返回专用退出码 2——与「断言失败=1」区分,防致命类套件的 expect 协议放过) | 已落实(退出码 0 但无该探针签名标记亦判红——防静默退出) | 不适用(golden 容差 + 取证存在性判定) | 不适用(逐规则命中判定;另有扫描面为空即判红的零命中自检) | 背景事实(run_smoke 头注在案):GDScript 运行时脚本错误不改进程退出码、不计入冒烟失败数、且静默中断所在函数剩余断言——2026-09-19 状态记录 83 实证「309 项 0 失败」实为断言未跑全。这是 SCRIPT ERROR 扫描必须存在于判定层的原因。 @@ -230,13 +231,15 @@ ### 三层承载与判定原则 -测试分三层,各有在册承载与入闸方式(细目见 `game/tests/README.md`): +测试分标准四级+UI 探针(2026-10-22 用户裁,测试术语与层级重构批),各有在册承载与入闸方式(细目见 `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) | +| 冒烟测试 | 这版活着吗(零逻辑判定) | `game/tests/smoke_boot.tscn/.gd` | `run_smoke.py`(闸 00) | +| 单元测试 | 最单元的逻辑代码对不对 | `game/tests/unit/` | `run_all.gd`(闸 02) | +| 集成测试 | 跨系统闭环对不对 | `game/tests/integration/` | 同上(同一入口同一协议) | +| 系统测试 | 真实玩法路径断言面 | 过渡期 `game/smoke/smoke_all.gd`(迁移后 `game/tests/system_test.gd`) | `run_system_test.py`(闸 01) | +| UI 探针+渲染 golden | 场景切换与观感 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04)、`render_gate.sh`(闸 05) | **判定原则**:验收不能只是「跑一遍没崩」。三硬闸(退出码/脚本错误扫描/断言数下限)是判定的底线, 缺一即判定有洞;「日志里写着 PASS」不作判据。另有两类静默失效必须机制性防范——规则扫描面为空 diff --git a/design/engineering/process-conventions.md b/design/engineering/process-conventions.md index ca0261b9..2227958a 100644 --- a/design/engineering/process-conventions.md +++ b/design/engineering/process-conventions.md @@ -10,7 +10,7 @@ 主工作区(master 检出)只作合并落点,一切开发/设计改动禁止直接发生在主工作区: 1. **开工**:每批次先建独立 worktree:`git worktree add ../g-plan2-<任务名> -b <任务分支>`;所有改动与验证都在 worktree 内进行(未跟踪文件不会出现在新 worktree,需手工搬运) -2. **收尾**:迭代 loop 批次验证全绿(冒烟+场景探针)后在主工作区 `git merge --no-ff <任务分支>`,再 `git worktree remove`+删分支;合回前若 master 已被别的批次推进,先在分支里 `git merge master` 解冲突、重跑本域闸再合回。非 loop 批次合回前同样按本批改动域跑闸(见 §3.10:纯文档批跑 `--docs`);游戏类测试(冒烟/单测/MC/探针)对它默认零执行(两入口铁规,见 §4),用户点名或当场问才跑 +2. **收尾**:迭代 loop 批次验证全绿(冒烟+场景探针)后在主工作区 `git merge --no-ff <任务分支>`,再 `git worktree remove`+删分支;合回前若 master 已被别的批次推进,先在分支里 `git merge master` 解冲突、重跑本域闸再合回。非 loop 批次合回前同样按本批改动域跑闸(见 §3.9:纯文档批跑 `--docs`);游戏类测试(冒烟/单测/MC/探针)对它默认零执行(两入口铁规,见 §4),用户点名或当场问才跑 3. **豁免面**:仅 `.zcode/`(钩子与配置)与 `.qoder/`(本机 harness 配置)允许主工作区直改——两份实测均不入 git(走 worktree 反而带不进去,改动只对本机生效);其余一律走 worktree,主工作区 git commit 被钩子拦截 4. 本铁律由 PreToolUse 钩子 `.zcode/hooks/worktree-guard.sh` 强制兜底;钩子只约束 agent,用户手工改动不受限 @@ -24,7 +24,7 @@ | 二·文档设计/美术设计 | doc-design(文档轨,兼任反哺点写入)/asset-ingest(美术录入;样张素材由用户主导产出) | GDD 文档或 art-bible/过审样张素材 | 用户提起或从待办清单取出;会话内进行、用户主导、禁无监管自跑;经用户点头确认心智一致才许写入文档;完成判据=待裁点清零,带未裁点的是讨论稿;轨闭合凭据记入待办清单现状清单(文档轨=文档路径+版本号,美术轨=art-bible 条款版本) | | 三·写需求卡片 | req-card | 需求卡片 | 轨闭合后用户发令;新玩法/新系统/新内容必须有设计定稿前置,例外=工程直入四类(缺陷修复/工程债/测试基建/重构)凭证据跳过设计轨;卡片必经 grilling 多轮复核、零待裁点才成立;写完在待办清单状态记「已写卡」,卡片路径记入现状清单(防遗忘) | | 四·迭代编排 | iter-plan | roadmap 迭代行 | 编排四查:①每卡卡片文件存在 ②每卡设计闭合凭据齐(或工程直入证据)③总估时三条线——所选卡估时按区间**中值**求和(单位工作日、写精确值不取整):低于 5 个工作日不得编(硬下限,2026-10-21 用户立:不希望单迭代编太少);5–10 个工作日为常规区间、直接编;超过 10 个工作日须经用户确认(确认前呈报整批/拆批两案等裁,禁自行拆分或塞入),确认后迭代行随带日期特批标记;超过 20 个工作日不得编(硬上限,2026-10-21 用户立:用户确认也不放行,必须拆批)④写完 roadmap 行后跑流水线闸确认绿;编入未完成迭代,新开迭代行经用户确认 | -| 五·实现 | iter | 代码与配置,可运行的游戏 | 需求卡片永不单独施工,统一由用户指定迭代号触发(=U0 开工令),按 iter 技能与本文 §3 执行;**本段是代码实现批:禁写 `design/` 与根规则文件,产出物只落 `reports/`(见 §3 第 13 条)**;设计权威文档(GDD/art-bible)批内一律不更新;完成人闸=实机目检清单+代拍板追认单登记交用户即完成批内义务 | +| 五·实现 | iter | 代码与配置,可运行的游戏 | 需求卡片永不单独施工,统一由用户指定迭代号触发(=U0 开工令),按 iter 技能与本文 §3 执行;**本段是代码实现批:禁写 `design/` 与根规则文件,产出物只落 `reports/`(见 §3 第 12 条)**;设计权威文档(GDD/art-bible)批内一律不更新;完成人闸=实机目检清单+代拍板追认单登记交用户即完成批内义务 | **实现段零裁断**:实现段的输入在编排段出口已锁死(卡片零待裁点+设计闭合凭据齐+编排四查过)。实现中冒出卡片没写清的分歧=上游缺陷——停该卡、待办清单状态回退+缺口补记,退回上游;禁自答、禁内联提问求裁断;该卡停摆不阻塞同迭代其他卡。环境故障与测试红走异常分级(自救重试/记录继续/停机呈报)。代拍板白名单保留:授权发生在卡片审问阶段,实现只执行预授权;批后追认单=备案确认。 @@ -40,23 +40,22 @@ 3. **用户发开工令才动手**——需求卡片经用户批准(U0)后方开工 **环境与基线** -4. **独立 worktree+拷导入缓存**——按 §1 建 worktree;先拷主区 `.godot/` 目录与字体 `.import` 边车再跑测试,否则全量重导入资源近似卡死 -5. **改码前定基线三查**——记录冒烟全绿数目、单元测试套数、现行模拟基线读数;改完同口径逐项比对,红绿才能归因到本批 -6. **涉数值改动先采改前读数**——与改后同随机种子配对比较并写明逐项比对口径;无数值改动禁跑模拟 +4. **独立 worktree+拷导入缓存**——按 §1 建 worktree;先拷主区 `.godot/` 目录与字体 `.import` 边车再跑测试,否则全量重导入资源近似卡死。检出工作区零测试,直接开工(2026-10-22 频率规则) +5. **涉数值改动先采改前读数**——与改后同随机种子配对比较并写明逐项比对口径;无数值改动禁跑模拟 **实施** -7. **新功能必配新测试**——新增玩法/机制必须配套新测试套件进 `tests/run_all`;没有对应测试的变更不许合并;并按 test-conventions §9 挂进改动—验收对照表,没有对照表行的行为改动同样不许合并 -8. **验收改动先行声明**——验收变化(基线换代/探针增改/旧断言改判)预先写进需求卡片,禁事后补 -9. **验收函数变异验证**——新增/改写的验收函数(冒烟断言/探针自检/闸机判据)必须配一次变异验证:故意把被测件改坏成该类缺陷形态 → 跑最窄那道闸确认变红 → `git checkout` 精确还原;变异点/跑的闸/红输出摘要/还原结论四件记入实现报告变异验证记录表;**无变异红记录的断言不写「已验收」,只写「已实现,拦截能力未证」**(细则见 test-conventions §6);行为改动另做撤销证明(test-conventions §9.4),两类证明都记入实现报告 +6. **新功能必配新测试**——新增玩法/机制必须配套新测试套件进 `tests/run_all`;没有对应测试的变更不许合并;并按 test-conventions §9 挂进改动—验收对照表,没有对照表行的行为改动同样不许合并 +7. **验收改动先行声明**——验收变化(基线换代/探针增改/旧断言改判)预先写进需求卡片,禁事后补 +8. **验收函数变异验证**——新增/改写的验收函数(冒烟断言/探针自检/闸机判据)必须配一次变异验证:故意把被测件改坏成该类缺陷形态 → 跑最窄那道闸确认变红 → `git checkout` 精确还原;变异点/跑的闸/红输出摘要/还原结论四件记入实现报告变异验证记录表;**无变异红记录的断言不写「已验收」,只写「已实现,拦截能力未证」**(细则见 test-conventions §6);行为改动另做撤销证明(test-conventions §9.4),两类证明都记入实现报告 **完成** -10. **批末按域验收才合并**——在任务 worktree 内跑 `tools/run_all_gates.sh`,**按本批改动域选档**:本批改动触及 `game/`(游戏工程代码、测试、探针、场景)→ `--code`;不触及 `game/` 的一切改动(`design/`、根规则文件、`tools/` 工具脚本、`.zcode/` 配置等)→ `--docs`;同批混触 `game/` 与文档面 → 全量档不带参数。所选域全绿才合回 master。合回前独立验收审计通过(iter 技能第 5 步),未过不合回。该脚本是全部验证闸的**唯一入口、唯一清单与唯一域划分**,闸件增减、分域调整都只改它;**本文与其他文档禁另列闸件清单**(清单散落多处正是 iter27 漏跑 `probe_gate --full` 的成因)。跑完写回执 `game/tests/.gates/receipt.json`(含 `domain` 字段),合回时由 worktree-guard 按 diff 路径推域强制校验(五况拦截:缺回执/quick 模式/回执 commit ≠ HEAD/域不覆盖本次改动/含新增红)。闸件(`tools/` 下机检脚本与闸壳)自身的改动不占合回全量——其质量义务=落批时的变异验证(test-conventions §6),闸真崩溃时合回闸 fail-closed(跑不出回执即拦),不以合回全量为兜底 -11. **合并后主区按域复跑**——合并可能引入冲突或环境差异,落回 master 后按本批改动域复跑:触及 `game/` 的批=全量复跑(维持);纯文档域批=复跑文档域档(秒级),不再全量 -12. **批次完成后的登记义务**——合回 master 后按 doc-conventions §12.1 推进待办清单条目状态(做完整条删除),涉及迭代编排的在 roadmap 迭代总表与顶部「当前位」节换代——执行入口=项目技能 batch-closure(批后收尾与中间文档退役),跨会话教训写记忆库。批次过程与结论由 git 提交信息承载,禁止再写会话状态文件(见 doc-conventions 第 10 节)。`design/` 与根规则文件(`AGENTS.md`/`roadmap.md`/`待办清单.md`/`体验验证清单.md`)实现批禁写,见 §13 -13. **文档同步闸(实现批禁写文档)**——实现批**禁写 `design/` 整个目录与根规则文件**,要往外交代的东西一律落 `reports/`:实现报告 `reports/iter-实现报告-<日期>.md`(各卡状态+实机目检总清单+遗留读数义务)、代拍板追认单 `reports/iter-代拍板-<日期>.md`、GDD 反哺点清单 `reports/iter-反哺点-<日期>.md`(每条写清哪个文件哪一节、改成什么、依据是什么)。设计权威文档(GDD/art-bible)**任何批都不直改**:用户在会话中逐点裁决后,由非迭代批(工具/规则/文档批)写入 `design/`(执行入口=doc-design 技能),并把反哺条目从 `reports/` 清掉(吸收即死;退役执行=batch-closure 技能)。机械面文档(roadmap 行/术语表/README 工具注)同样归非迭代批在合并前更新。反哺点未落地期间相关 GDD 节视为待反哺,读前先查 `reports/` 与待办清单;需求卡片必列「关联文档同步面」按两层标注 -14. **实机目检归用户,只登记不阻塞**——真人检测项登记进实现报告目检总清单与体验验证清单即完成批内义务;禁把等目检当停工理由或完成前置 -15. **中间产物吸收即死**——细则见 AGENTS.md 硬性红线与 doc-conventions 第 11 节 -16. **改动级验收闭合**——写卡期列需求侧对照表,实现期补实际对照表(测试文件:行),每个行为改动做撤销证明;合回前独立验收审计。细则=test-conventions §9 +9. **批末按域验收才合并**——在任务 worktree 内跑 `tools/run_all_gates.sh`,**按本批改动域选档**:本批改动触及 `game/`(游戏工程代码、测试、探针、场景)→ `--code`;不触及 `game/` 的一切改动(`design/`、根规则文件、`tools/` 工具脚本、`.zcode/` 配置等)→ `--docs`;同批混触 `game/` 与文档面 → 全量档不带参数。所选域全绿才合回 master。合回前独立验收审计通过(iter 技能第 5 步),未过不合回。该脚本是全部验证闸的**唯一入口、唯一清单与唯一域划分**,闸件增减、分域调整都只改它;**本文与其他文档禁另列闸件清单**(清单散落多处正是 iter27 漏跑 `probe_gate --full` 的成因)。跑完写回执 `game/tests/.gates/receipt.json`(含 `domain` 字段),合回时由 worktree-guard 按 diff 路径推域强制校验(五况拦截:缺回执/quick 模式/回执 commit ≠ HEAD/域不覆盖本次改动/含新增红)。闸件(`tools/` 下机检脚本与闸壳)自身的改动不占合回全量——其质量义务=落批时的变异验证(test-conventions §6),闸真崩溃时合回闸 fail-closed(跑不出回执即拦),不以合回全量为兜底 +10. **合并后主区按域复跑**——合并可能引入冲突或环境差异,落回 master 后按本批改动域复跑:触及 `game/` 的批=全量复跑(维持);纯文档域批=复跑文档域档(秒级),不再全量 +11. **批次完成后的登记义务**——合回 master 后按 doc-conventions §12.1 推进待办清单条目状态(做完整条删除),涉及迭代编排的在 roadmap 迭代总表与顶部「当前位」节换代——执行入口=项目技能 batch-closure(批后收尾与中间文档退役),跨会话教训写记忆库。批次过程与结论由 git 提交信息承载,禁止再写会话状态文件(见 doc-conventions 第 10 节)。`design/` 与根规则文件(`AGENTS.md`/`roadmap.md`/`待办清单.md`/`体验验证清单.md`)实现批禁写,见 §12 +12. **文档同步闸(实现批禁写文档)**——实现批**禁写 `design/` 整个目录与根规则文件**,要往外交代的东西一律落 `reports/`:实现报告 `reports/iter-实现报告-<日期>.md`(各卡状态+实机目检总清单+遗留读数义务)、代拍板追认单 `reports/iter-代拍板-<日期>.md`、GDD 反哺点清单 `reports/iter-反哺点-<日期>.md`(每条写清哪个文件哪一节、改成什么、依据是什么)。设计权威文档(GDD/art-bible)**任何批都不直改**:用户在会话中逐点裁决后,由非迭代批(工具/规则/文档批)写入 `design/`(执行入口=doc-design 技能),并把反哺条目从 `reports/` 清掉(吸收即死;退役执行=batch-closure 技能)。机械面文档(roadmap 行/术语表/README 工具注)同样归非迭代批在合并前更新。反哺点未落地期间相关 GDD 节视为待反哺,读前先查 `reports/` 与待办清单;需求卡片必列「关联文档同步面」按两层标注 +13. **实机目检归用户,只登记不阻塞**——真人检测项登记进实现报告目检总清单与体验验证清单即完成批内义务;禁把等目检当停工理由或完成前置 +14. **中间产物吸收即死**——细则见 AGENTS.md 硬性红线与 doc-conventions 第 11 节 +15. **改动级验收闭合**——写卡期列需求侧对照表,实现期补实际对照表(测试文件:行),每个行为改动做撤销证明;合回前独立验收审计。细则=test-conventions §9 ## 4. 测试执行两入口铁规(细则) diff --git a/design/engineering/test-conventions.md b/design/engineering/test-conventions.md index 6215218f..8ee9a53f 100644 --- a/design/engineering/test-conventions.md +++ b/design/engineering/test-conventions.md @@ -115,6 +115,8 @@ - **反向**:diff 里每个行为改动至少一行;没有对应行的改动,必须写明「非行为改动」或「由第 X 行覆盖」。 - **测试侧**:新增或改写的每条断言至少挂一行;挂不上的孤儿断言删掉或补挂到需求点。 +**验证集清单维护锚(2026-10-22 用户裁)**:`game/tests/验证集清单.md` 是迁移断言全体的冻结参照清单(机器提取生成、只剪切禁改写、出口逐条核对,机制见该文件头注;清单自断言迁移批第 0 步建立时起存在)——清单在册后,任何断言**新增、删除或改判**必须同步该清单,与改动—验收对照表同一条纪律:清单没跟上的断言改动视同未完成。 + ### 9.4 撤销证明 - **修改既有行为**:只撤销该改动(回退该 hunk 或该处接线)→ 跑能覆盖它的最窄测试 → 对应断言必须变红 → 还原 → 复绿。 diff --git a/design/gdd/术语表.md b/design/gdd/术语表.md index 2b3eff55..202d3e0c 100644 --- a/design/gdd/术语表.md +++ b/design/gdd/术语表.md @@ -146,6 +146,10 @@ | 插旗 | 封印(2026-10-18 用户裁:封印=正式动作名,全库统一,非比喻非「对外表达」别名) | | 旗(游戏概念语境) | 封印印记(2026-10-18 用户裁:「旗」概念不存在——封印动作落下的印记称封印印记) | | flag_*(代码契约键/标识符) | seal_*(2026-10-18 用户裁全族换代;迭代 31 卡① 已清零) | +| 系统级断言套件 / 冒烟全量 | 系统测试(2026-10-22 用户裁——标准四级测试层之一:启动真实工程走真实玩法路径的断言面;依据=reports/提案-测试术语与层级重构方案-2026-10-22.md §二) | +| 单元套件 | 单元测试(2026-10-22 用户裁——标准四级测试层之一,「套件」单独成词非必要) | +| 启动探针 / 秒级冒烟探针 / L0–L3 | 冒烟测试(2026-10-22 用户裁——极小启动场景:能否启动、能否加载表、能否进主屏,零逻辑判定) | +| 轻闸 / 重闸 / 三查 | (随 2026-10-22 频率规则消亡,无替代词——检出工作区零测试;卡内只测自己改的东西;全量只在收尾与合回两个时机。机制词「去重闸」含「重闸」二字但与此无关,不在退役之列) | ## 对外命名纪律(玩家可见命名,2026-10-11 用户令) diff --git a/game/smoke/level_autoplay.gd b/game/smoke/level_autoplay.gd index 4834acc2..662e756c 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/smoke/level_autoplay.gd @@ -258,7 +258,9 @@ static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, # 三选一停点驱动(确定性策略;TP0 起 316-f 判读:1 级最稀有、同档并列取最左; # 奖励事件随 reward_events 留档) static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void: - st.reward_events.append({"curio_pick": payload.duplicate(true)}) + var pl := payload.duplicate(true) + pl["bonus_max_hp"] = int(adr.run_ledger.bonus.get("max_hp", 0)) # 观测面:停点时刻加算区快照(回血精确断言用) + st.reward_events.append({"curio_pick": pl}) var candidates: Array = payload.get("candidates", []) as Array if candidates.is_empty(): st.result = "advance_failed" diff --git a/game/smoke/smoke_all.gd.uid b/game/smoke/smoke_all.gd.uid deleted file mode 100644 index 9afd813a..00000000 --- a/game/smoke/smoke_all.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bk7yf7sf5w3h3 diff --git a/game/tests/.gates/baseline.json b/game/tests/.gates/baseline.json index 3574e164..4fe9b1a7 100644 --- a/game/tests/.gates/baseline.json +++ b/game/tests/.gates/baseline.json @@ -1,61 +1,65 @@ { - "note": "存量基线·首次自动生成;闸况变化时本脚本会判红,需人工确认后重生成;2026-10-17 人工确认重生成 06 条目:af94ce97 把 gdd_lint 记为存量红(01 篇 R5),该红已由 fcb8c3f8 补流程图消除,实测 --docs 域 06 exit=0,故基线改回绿。2026-10-18 新增闸件 08c 流水线闸(tools/pipeline_gate.py 注册上电),手工加入绿记录(roadmap 压缩批实测 --docs 域 exit=0),其余条目原样保留。", - "gates": { - "01 冒烟全量": { - "exit": "0", - "fp": "green" - }, - "02 单元套件": { - "exit": "0", - "fp": "green" - }, - "03 探针 --click": { - "exit": "0", - "fp": "green" - }, - "04 探针 --full": { - "exit": "0", - "fp": "green" - }, - "05 渲染闸": { - "exit": "0", - "fp": "green" - }, - "06 gdd_lint": { - "exit": "0", - "fp": "green" - }, - "06b 变更记录节": { - "exit": "0", - "fp": "green" - }, - "07 doc_link": { - "exit": "0", - "fp": "green" - }, - "08 doc_term": { - "exit": "0", - "fp": "green" - }, - "08c 流水线闸": { - "exit": "0", - "fp": "green" - }, - "09 静态扫描": { - "exit": "1", - "fp": "4354b2aa72a7" - }, - "10 熵增闸": { - "exit": "0", - "fp": "green" - }, - "11 回放确定性对拍": { - "exit": "SKIP", - "fp": "skip" - }, - "08d 引用可核": { - "exit": "0", - "fp": "green" - } + "note": "存量基线·首次自动生成;闸况变化时本脚本会判红,需人工确认后重生成;2026-10-17 人工确认重生成 06 条目:af94ce97 把 gdd_lint 记为存量红(01 篇 R5),该红已由 fcb8c3f8 补流程图消除,实测 --docs 域 06 exit=0,故基线改回绿。2026-10-18 新增闸件 08c 流水线闸(tools/pipeline_gate.py 注册上电),手工加入绿记录(roadmap 压缩批实测 --docs 域 exit=0),其余条目原样保留。2026-10-22 测试术语与层级重构批 A 改键:01 冒烟全量→01 系统测试(同一断言面 smoke_all.gd,运行器换 tools/run_system_test.py,过渡期判定口径不变)、02 单元套件→02 单元与集成测试(同一 run_all.gd 入口,名从实)、新增 00 冒烟测试(tools/run_smoke.py+game/tests/smoke_boot.tscn,重写后单跑实测 17 项 0 失败 PASS);09 静态扫描基线 exit=1(fp=4354b2aa72a7)系滞后红,本批单跑实测 exit=0(与 2026-10-22 16:20 回执一致),基线改回绿;其余条目原样保留。;2026-10-23 主会话记 07 doc_link 为存量红(用户令忽略:锻造工坊.md 死链的修复在另一会话未合入,合回闸豁免;外部修复合入后应重生成基线归绿)", + "gates": { + "00 冒烟测试": { + "exit": "0", + "fp": "green" + }, + "01 系统测试": { + "exit": "0", + "fp": "green" + }, + "02 单元与集成测试": { + "exit": "0", + "fp": "green" + }, + "03 探针 --click": { + "exit": "0", + "fp": "green" + }, + "04 探针 --full": { + "exit": "0", + "fp": "green" + }, + "05 渲染闸": { + "exit": "0", + "fp": "green" + }, + "06 gdd_lint": { + "exit": "0", + "fp": "green" + }, + "06b 变更记录节": { + "exit": "0", + "fp": "green" + }, + "07 doc_link": { + "exit": "1", + "fp": "red-nokeys" + }, + "08 doc_term": { + "exit": "0", + "fp": "green" + }, + "08c 流水线闸": { + "exit": "0", + "fp": "green" + }, + "09 静态扫描": { + "exit": "0", + "fp": "green" + }, + "10 熵增闸": { + "exit": "0", + "fp": "green" + }, + "11 回放确定性对拍": { + "exit": "SKIP", + "fp": "skip" + }, + "08d 引用可核": { + "exit": "0", + "fp": "green" } + } } \ No newline at end of file diff --git a/game/tests/README.md b/game/tests/README.md index d2d6abb2..dd91fb24 100644 --- a/game/tests/README.md +++ b/game/tests/README.md @@ -1,65 +1,79 @@ # Test Infrastructure — Dungeon Sweeper **Engine**: Godot 4.7.2-stable(GDScript 单语言) -**Test Framework**: 双载体——`--headless --script` 单元套件 + headless 场景探针;gdUnit4 **deferred**(D2 OQ1 关闭形态——本仓库不引第三方测试框架,需求见下「门禁」节) -**门禁**: 本地闸——`tools/run_all_gates.sh`(收口唯一验收入口,按改动域选档 `--code`/`--docs`,跨两域或改闸件本身不带参数=全量;`--quick` 为中段自检)。本仓库**无远端、无 CI**(`.github/workflows/` 已删除,不建不留不参考);闸清单与域划分只在本脚本定义,文档不另列 +**Test Framework**: 标准四级测试层(冒烟/单元/集成/系统)+ headless 场景探针与渲染 golden;gdUnit4 **deferred**(D2 OQ1 关闭形态——本仓库不引第三方测试框架,需求见下「门禁」节) +**门禁**: 本地闸——`tools/run_all_gates.sh`(收口唯一验收入口,按改动域选档 `--code`/`--docs`,跨两域或改闸件本身不带参数=全量)。本仓库**无远端、无 CI**(`.github/workflows/` 已删除,不建不留不参考);闸清单与域划分只在本脚本定义,文档不另列 **Setup date**: 2026-09-12(2026-09-27 `7f24b640` 三层收敛:根 tests/ 并入 `game/tests/`、docs 并入 `design/engineering/`、production 解散) -**参考实现**: `game/smoke/`(冒烟断言 + 场景探针族——正式工程的活样板;断言计数双口径见下节) +**参考实现**: `game/smoke/`(场景探针族——正式工程的活样板;系统测试断言面过渡宿主 `smoke_all.gd` 亦在此,迁移归断言迁移批) ## Directory Layout ``` tests/ - unit/ # 隔离单元测试(公式/状态机/纯逻辑)——extends SceneTree + quit(code) - integration/ # L2 集成用例(跨系统闭环——与 unit/ 的隔离断言面分工,见下「分层」节) - smoke/ # critical-paths.md——人工 gate 清单(/smoke-check 读取) - evidence/ # 截图与人工签核记录 - run_all.gd # 单元套件统一入口(套件 manifest 协议,D2 AC-2b) - static_scan.sh # 静态扫描域 S1–S12 可执行子集 + unit/ # 单元测试(公式/状态机/纯逻辑,隔离断言)——extends SceneTree + quit(code) + integration/ # 集成测试(跨系统闭环——与 unit/ 同一入口同一协议,见下「测试层级」节) + smoke_boot.tscn/.gd # 冒烟测试——极小启动场景(真实开机路径存活检查,闸 00) + system_test.gd # 系统测试宿主薄壳(SceneTree+构造注入;断言迁移批后承接 smoke_all.gd,闸 01) + smoke/ # critical-paths.md——人工 gate 清单(/smoke-check 读取) + evidence/ # 截图与人工签核记录 + run_all.gd # 单元与集成测试统一入口(套件 manifest 协议,D2 AC-2b) + static_scan.sh # 静态扫描域 S1–S12 可执行子集 ``` -## 测试三层与承载目录 +## 测试层级(标准四级+UI 探针) | 层 | 判什么 | 承载 | 入闸方式 | |---|---|---|---| -| **L1 单元** | 最单元的逻辑代码对不对(公式/状态机/纯逻辑) | `tests/unit/` | `run_all.gd` manifest(闸 02) | -| **L2 集成** | 跨系统的闭环对不对 | `tests/integration/` | 同上(与单元套件同一入口、同一协议) | -| **L3 系统/场景** | 跑起来之后 UI 与场景切换对不对 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04) | +| **冒烟测试** | 这版活着吗——能否启动、能否加载表、能否进主屏。零逻辑判定 | `tests/smoke_boot.tscn/.gd`(以项目运行目标拉起=真实开机路径) | `run_smoke.py`(闸 00) | +| **单元测试** | 最单元的逻辑代码对不对(公式/状态机/纯逻辑) | `tests/unit/` | `run_all.gd` manifest(闸 02) | +| **集成测试** | 跨系统的闭环对不对 | `tests/integration/` | 同上(与单元测试同一入口、同一协议) | +| **系统测试** | 启动真实工程走真实玩法路径 | 过渡期 `game/smoke/smoke_all.gd`(断言迁移批后=`tests/system_test.gd`) | `run_system_test.py`(闸 01) | +| **UI 探针+渲染 golden** | 跑起来之后 UI 与场景切换对不对;画面基线 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04)、`render_gate.sh`(闸 05) | > **integration/ 已建承载**(迭代 29 T3——此前只有章程句无实体,空壳曾于迭代 19 卡① T4 删过一次)。 > 首个集成用例 = `save_rng_roundtrip_test.gd`(存档⇄RNG 快照往返)。章程登记的四类集成用例里, -> 回放确定性已由 `tools/parity_anchor.py` 承担并收编为重闸 11 号条件闸件; +> 回放确定性已由 `tools/parity_anchor.py` 承担并收编为全量域闸 11 号条件闸件; > Core-Core 与 A1×A2 端到端仍随属主批增量登记;各 GDD 登记的 deferral 集成用例以对侧登记为生效条件。 > -> **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = L1; -> 需要两个以上真实系统协同并走落盘/落库 = L2;需要完整项目引导与场景树 = L3。 +> **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = 单元测试; +> 需要两个以上真实系统协同并走落盘/落库 = 集成测试;需要完整项目引导与场景树 = 冒烟/系统/UI 探针。 ## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正) 历史 README 写死「752+ 断言/十五套件」必然漂移,改为两种口径并注明语义: -- **运行时口径**:`python tools/run_smoke.py` 打印的通过项数(迭代 19 卡①分支基点实测 **840 项 0 失败**,2026-10-08;master 合并卡②④后为 846——随断言面增减浮动,以当次跑测打印为准)——循环内断言按执行次数计。 -- **静态口径**:`game/smoke/` 源码 `check(` 调用点计数(2026-10-08 本分支实测 **764 处**)——按源码位置计,一处在循环内即多次执行,故两口径数值不同且各自浮动,禁互相换算或写死。 +- **运行时口径**:系统测试运行器(`tools/run_system_test.py`)打印的通过项数——循环内断言按执行次数计,随断言面增减浮动,以当次跑测打印为准。 +- **静态口径**:断言面源码 `check(` 调用点计数——按源码位置计,一处在循环内即多次执行。 + +两口径数值不同且各自浮动,禁互相换算或写死。 ## Running Tests ```bash -# 单元套件(双载体之一)——Windows 须用 console 版 exe 取 stdout;工作目录 = game/(含 project.godot 那一层) +# 冒烟测试(闸 00)——极小启动场景,秒级 +python tools/run_smoke.py + +# 单元与集成测试(闸 02)——Windows 须用 console 版 exe 取 stdout;工作目录 = game/(含 project.godot 那一层) godot --headless --script tests/run_all.gd # 统一入口(本地闸 02 闸调用点) godot --headless --script tests/unit/example_test.gd -# 场景探针(双载体之二)——以探针场景为运行目标,走完整项目引导(autoload 在位) +# 系统测试(闸 01)——过渡期宿主 smoke_all.gd;分钟级 +python tools/run_system_test.py + +# 场景探针(UI 探针层)——以探针场景为运行目标,走完整项目引导(autoload 在位) godot --headless --path . res://smoke/.tscn # 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置) timeout 120s godot --headless --script tests/run_all.gd ``` -> 单元/契约套件状态(单元测试与契约层批 2026-09-28 立基,后随迭代 7/8 增补):`_suites()` manifest 已登记实跑 **35 套件**(2026-10-21 迭代 32 卡② 后现况,含 `counter_prefix_fatal_test` 负面套件;上一次计数变动为 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 统一流。 +> 套件 manifest 现况:`_suites()` 登记 **40 条**(39 单元+1 集成,2026-10-22 实数;口径=manifest 条目数, +> 随套件增减浮动)——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、core(秘宝结构冻结/断点续玩/存档槽位/结算五段等)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言)、presentation(节奏队列/奖励浮层/字体资产/地形瓦片集/模态编排等);集成 1 条存 `tests/integration/`。致命类独立进程 `expect=1`;负面套件 `negative=true` 只豁免错误扫描一闸。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。 **协议要点**(D2 冒烟协议): -- 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)` +- `--script` 载体 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)` - `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入 +- 冒烟测试走项目运行目标(场景拉起),autoload 全在位——与 `--script` 载体互补 - 致命类用例独立进程,runner manifest 登记"调用 → 期望退出码" - 每用例先 `reset_diagnostics()`(诊断收集器协议) - 期望值由表读值推导,不写死玩法数值 @@ -78,23 +92,24 @@ timeout 120s godot --headless --script tests/run_all.gd | Integration | Integration test OR playtest doc | 落地时随批建 `tests/integration/[system]/`(目录已删空壳,章程见上) | | Visual/Feel | Screenshot + lead sign-off | `tests/evidence/` | | UI | Manual walkthrough OR interaction test(场景探针为首选) | `tests/evidence/` | -| Config/Data | Smoke check pass | 跑批回执 `game/tests/.gates/receipt.json`(不入库)+结论随批记 `reports/` | +| Config/Data | Smoke test pass | 跑批回执 `game/tests/.gates/receipt.json`(不入库)+结论随批记 `reports/` | ## game/smoke/ 游离脚本台账(迭代 29 T7 逐个定性) -`game/smoke/` 有 29 个 `.gd`,其中 14 个在闸(探针有 `.tscn` 宿主、被 `probe_gate.sh`/`run_smoke.py` 调用), -其余 15 个**不带 `.tscn`、不入任何闸**——它们是手动入口的测量/诊断件。逐个定性如下, +`game/smoke/` 里的脚本两分:**在闸件**(探针有 `.tscn` 宿主、被 `probe_gate.sh`/`render_gate.sh` 调用; +`smoke_all.gd` 被系统测试运行器调用——过渡期宿主)与**不入闸的手动测量/诊断件**(下表)。 判据 = 头注自陈用途 + 有无外部引用(工具脚本/产品码/文档)。**存疑一律按在役登记并标注**(不退役)。 | 脚本 | 定性 | 用途与触发方式 | 结果由谁判 | |---|---|---|---| -| `adventure_monte_carlo.gd` | **在役**(`AGENTS.md` 在册入口) | 全冒险期望 MC;`tools/parity_anchor.py` 的对拍载体 | 工具脚本逐位比对 | +| `adventure_monte_carlo.gd` | **在役**(`AGENTS.md` 在册入口) | 全冒险期望 MC;`tools/parity_anchor.py` 的比对载体 | 工具脚本逐位比对 | | `verifier_batch.gd` | **在役**(`AGENTS.md` 在册入口) | `tools/balance_verifier.py` 的采集端(数值验证) | 报告与原始数据落 `sim-data/` | | `reward_expectation.gd` | **在役**(`AGENTS.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 | 宿主脚本 | +| `probe_ui_util.gd` | 在役(库件) | 探针公共工具(存档隔离/句柄解析)——被各探针 preload | 宿主探针 | | `board_stat_probe.gd` | 在役 | `tools/clear_dominance_audit.py` 的取数端 | 工具脚本 | | `calibrate_boards.gd` | 在役 | 生成器真值分母;`sim-data/analyze.py` 引用 | 工具脚本 | | `damage_probe.gd` | 在役(诊断件) | 零承伤根因分割探针(头注自陈:非验收件·不入闸) | 人工读 stdout | @@ -114,7 +129,7 @@ timeout 120s godot --headless --script tests/run_all.gd `tests/static_scan.sh` 头注**——迭代 32 两卡是代码实现批,按流水线红线禁写 `design/`,故这两段未进架构文档。 新违规案例必须**先补扫描规则再修码**。 -S11 代码注释禁用词:扫描 game/ 全域 .gd/.tres/.md/.json/.sh 的注释面;禁词全部由 `design/gdd/术语表.md`「退役词对照」第一列解析供给(脚本内不内联任何词面——单一事实源);解析结果再按「巢穴」「旗」「flag_*」三条整行排除;三层豁免(闸门自指/显式留档 4 处/非注释面天然不取);两道自检(扫描面文件数非空+词表解析非空)。 +S11 代码注释禁用词:扫描 game/ 全域 .gd/.tres/.md/.json/.sh 的注释面;禁词全部由 `design/gdd/术语表.md`「退役词对照」第一列解析供给(脚本内不内联任何词面——单一事实源);解析结果再按「巢穴」「旗」「flag_*」三条整行排除,另设同形异义豁免「去重闸」(封印重判去重的机制判别器,与某闸档退役词后二字同形——行内含「去重闸」整行跳过);三层豁免(闸门自指/显式留档 4 处/非注释面天然不取);两道自检(扫描面文件数非空+词表解析非空)。 S12 效果引擎无状态化旧件零残留:扫描 game/ 全域 `.gd` 与 `game/data/tables/*.tres`,禁已删符号(`counters_export`/`counters_seed`/`_reset_tree_limited_counters` 等)与带引号的旧计数器 id 字面(引号锚定,不误伤 `adv_`/`round_` 前缀新名);扫描面文件数为零即判红。 @@ -132,17 +147,17 @@ S12 效果引擎无状态化旧件零残留:扫描 game/ 全域 `.gd` 与 `gam ③**断言数为零即红**——`test_kit.gd` 的 `finish()` 在零断言时返回专用退出码 2(与「断言失败=1」 区分,避免致命类套件的 expect 协议把它当成预期结果放过)。 -**gdUnit4 维持 deferred**:不引入第三方测试框架,本仓库在既有双载体(`--script` 套件 + 探针场景)内解决。 +**gdUnit4 维持 deferred**:不引入第三方测试框架,本仓库在既有载体(`--script` 套件+场景运行目标)内解决。 ## 术语(本项目内的唯一语义) +标准四级测试层(2026-10-22 用户裁,权威对照=design/gdd/术语表.md「退役词对照」): + | 词 | 语义 | 载体 | |---|---|---| -| **冒烟(smoke)** | **秒级存活检查**——能否启动、能否加载表、能否进主屏。判断「这版还活着吗」,不判断「这版对不对」 | `game/smoke/` 的存活面 | -| **单元套件** | 隔离的公式/状态机/纯逻辑断言面,逐套件独立进程实跑 | `game/tests/unit/`、`run_all.gd` | +| **冒烟测试** | 秒级存活检查——能否启动、能否加载表、能否进主屏。判断「这版还活着吗」,不判断「这版对不对」 | `tests/smoke_boot.tscn/.gd`、`tools/run_smoke.py` | +| **单元测试** | 隔离的公式/状态机/纯逻辑断言面,逐套件独立进程实跑 | `game/tests/unit/`、`run_all.gd` | +| **集成测试** | 跨系统闭环断言面(存档⇄RNG 往返、回放确定性等) | `game/tests/integration/`、`run_all.gd` | +| **系统测试** | 启动真实工程走真实玩法路径的断言面 | 过渡期 `game/smoke/smoke_all.gd`+`tools/run_system_test.py`;迁移后 `tests/system_test.gd` | | **场景探针** | 以探针场景为运行目标、走完整项目引导(autoload 在位)的 UI/场景断言 | `game/smoke/*.tscn`、`probe_gate.sh` | -| **集成用例** | 跨系统测试(Core-Core/回放确定性/存档⇄RNG 往返/端到端) | `game/tests/integration/` | - -> ⚠ **两处历史遗留命名,读代码时别被字面骗了**:`tools/run_smoke.py` 之名来自冒烟,但**它承载的是 -> 系统级断言面**(1127 项检查,远不止存活检查);`game/smoke/` 之名同理,里面既有存活面也有 -> 系统级断言与场景探针。判定「是不是冒烟」看语义,不看文件名。 +| **渲染 golden** | 窗口化截图与基线图的逐像素容差比对 | `render_gate.sh`、`tests/golden/` | diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd new file mode 100644 index 00000000..83a30d7e --- /dev/null +++ b/game/tests/integration/cross_system_flow_test.gd @@ -0,0 +1,183 @@ +# tests/integration/cross_system_flow_test.gd — L2 集成用例:跨系统真实流×落盘(批 B 转办块宿主) +# 断言面 = 四块 22 条(批 B 断言迁移自 smoke_all.gd,主会话裁决一/二/三/七改判集成,装配批落位; +# 标签与 cond 逐字搬运,宿主通道换线 Kit.check——与单元侧同一对账口径): +# RNG 事件日志会话落盘 1 条(源 `_initialize` :70,裁决一——RngService 会话日志×磁盘) +# 专长断点往返 3 条(源 `_smoke_feats` 基点 :1420–1426,裁决二——AdventureRunner 兑付态×SaveManager 断点快照) +# 存档摘要集成 10 条(源 `_smoke_save` ⑨ 基点 :6807–6831,裁决三——level_autoplay 真局×SaveManager 入账×写盘读回) +# 试玩记录仪 8 条(源 `_smoke_playtest_logger` 整套,裁决七——PlaytestLogger×BattleOrchestrator 真实结算流×JSONL 落盘) +# 协议照集成侧惯例(与单元同入口同协议,tests/README「测试层级」):extends SceneTree+Kit.check 主通道; +# --script 模式无 autoload,ConfigDB/RngService 显式实例化构造注入;表加载失败不中止(沿被迁断言面的 +# 源语义——诊断在此展开,红由具体断言落,承 G2–G5 同款);冒烟库件迁移期按现路径 preload(账本 §五夹具前提)。 +# 驱动代码随条自持、零新增断言(验证集不扩大为绝对约束——前置受理失败自然落到后续断言红)。 +# 运行:godot --headless --path game --script res://tests/integration/cross_system_flow_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const LevelAutoplay := preload("res://smoke/level_autoplay.gd") # 真局驱动(迁移期按现路径 preload——账本 §五夹具前提) + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2–G5 同款:表损坏的红落在具体断言上) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[cross_system_flow_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排(_initialize 尾条 → _smoke_feats 断点块 → _smoke_save ⑨ → _smoke_playtest_logger) + _rng_event_log_session() + _feats_breakpoint_roundtrip() + _save_summary_integration() + _smoke_playtest_logger() + quit(Kit.finish("cross_system_flow_test")) + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律;smoke_all.gd helper 的宿主自持私有副本) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# ---- ① RNG 事件日志会话落盘(源 `_initialize` :70,主会话裁决一改判集成—— +# 驱动沿源 :60–70 取号序列自持:master 种子 42 → 前置取号 → new_adventure 开日志会话 → 会话内取号)---- +func _rng_event_log_session() -> void: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + rng.open_stream("board_gen") + rng.open_stream("encounter") + rng.new_adventure(4242) + rng.open_stream("gp_drop") + Kit.check("事件日志会话落盘(2026-09-27 统一流裁决)", FileAccess.file_exists("user://logs/rng_log_4242.jsonl")) + + +# ---- ② 专长断点往返(源 `_smoke_feats` 基点 :1420–1426 三条,主会话裁决二改判集成—— +# 驱动沿源 :721–797 重建:5 行临时表注入+seed 72 起局+兑付 x3+save_manager 装配+ +# _on_level_finished(victory)+breakpoint_snapshot 取读+clear_breakpoint; +# 起局与兑付两步的受理断言留单元宿主(adventure_feats_test),此处纯驱动——前置失败自然落红)---- +func _feats_breakpoint_roundtrip() -> void: + var rt := FeatTable.new() + rt.rows = [ + {"feat_id": "x1", "display_name": "试一", "rarity": "common", "desc": "测试揭示", "cap": 1, + "triggers": [{"on": "mine_hit", "do": {"kind": "reveal_tiles", "value": 1}}]}, + {"feat_id": "x2", "display_name": "试二", "rarity": "excellent", "desc": "测试回复", "cap": 1, + "triggers": [{"on": "mine_hit", "do": {"kind": "heal", "value": 2}}]}, + {"feat_id": "x3", "display_name": "试三", "rarity": "rare", "desc": "测试群伤", "cap": 1, + "triggers": [{"on": "seal_hit", "do": {"kind": "open_ticket", "channel": "blast", "value": 3}}]}, + {"feat_id": "x4", "display_name": "试四", "rarity": "rare", "desc": "测试减伤", "cap": 2, + "triggers": [{"on": "damage_taken", "when": [{"fact": "hp_ratio", "op": "lte", "value": 50}], "do": {"kind": "damage_reduce", "value": 2}}]}, + {"feat_id": "x5", "display_name": "试五", "rarity": "legendary", "desc": "测试AC", "cap": 1, + "triggers": [{"on": "seal_hit", "do": {"kind": "ac_bonus", "value": 1, "mode": "round"}}]}, + ] + var live_rows: FeatTable = _db.feats + _db.feats = rt + var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) + a_r.start_adventure(72, {"first_script": true}) # 源 :723(受理断言留单元宿主) + a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}} + a_r.pick_curio("relic", "rare", "x3") # 源 :781(兑付断言留单元宿主) + # 断点往返(持有集+余库 feat_id 随行) + var bp_path := "user://_smoke_feats_bp.json" # 段首清场(存档域隔离纪律——上轮残留断点先删) + if FileAccess.file_exists(bp_path): + DirAccess.remove_absolute(bp_path) + var sm_r = load("res://core/save_manager.gd").new(bp_path) + sm_r.load_from_disk() + a_r.set_save_manager(sm_r) + a_r._on_level_finished({"result": "victory", "hp": int(a_r.ledger.get("hero_hp", 26)), "node_index": 1, "node_id": 1, "node_type": "shop"}) + var bp_r: Dictionary = sm_r.breakpoint_snapshot() + Kit.check("断点附随件:relics 持有集入快照(迭代 12 T1 扩列)", (bp_r.get("relics", []) as Array).has("x3")) + Kit.check("断点附随件:adventure 计数器账本键在案(T6/T7 扩列)", bp_r.has("adventure_counters")) + var bp_deck_x3 := 0 + for t in (bp_r.get("bonus_deck", []) as Array): + if String((t as Dictionary).get("feat_id", "")) == "x3": + bp_deck_x3 += 1 + Kit.check("断点余库:已兑付 relic 令牌逐枚还原(x3 零残留——续玩不重复获得)", bp_deck_x3 == 0) + sm_r.clear_breakpoint() + if FileAccess.file_exists(bp_path): # 段尾清理(与段首清场对称) + DirAccess.remove_absolute(bp_path) + _db.feats = live_rows # 还原现行表(共享 _db 防污染——后续集成块零感知) + + +# ---- ③ 存档摘要集成(源 `_smoke_save` ⑨ 基点 :6807–6831 十条,主会话裁决三改判集成—— +# level_autoplay 真局摘要 → apply 原语 → 域读数 → 写盘读回保持;临时档 user://smoke_save_integration.json +# 段首清场+段尾清理(源 _smoke_save 隔离纪律),绝不碰 user://save.json(红线))---- +func _save_summary_integration() -> void: + var sm_script = load("res://core/save_manager.gd") + var path9 := "user://smoke_save_integration.json" # 源 paths[8] + if FileAccess.file_exists(path9): + DirAccess.remove_absolute(path9) + # ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持 + var st9: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400) + var f9 = sm_script.new(path9) + f9.apply_adventure_summary(st9.get("summary", {})) + var ks_run := 0 + for li in range(1, int(_db.level.rows.size()) + 1): + ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0)) + Kit.check("摘要集成:冒险驱动满配通关(seed 42)", String(st9.get("result", "")) == "completed") + Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance(), f9.keystone_balance() == ks_run) + Kit.check("集成通关次数表全 1", f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1) + Kit.check("集成三关首杀标记齐", f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3)) + Kit.check("集成 recent 1 条且 seed 42", f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42) + var st9b: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400) + f9.apply_adventure_summary(st9b.get("summary", {})) + Kit.check("第二局后通关次数全 2", f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2) + Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.keystone_balance() == 2 * ks_run) + Kit.check("集成 recent 长度 2", f9.recent_summaries().size() == 2) + Kit.check("集成写盘成功", f9.write_to_disk()) + var r9 = sm_script.new(path9) + Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2 + and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3)) + if FileAccess.file_exists(path9): # 段尾清理(与段首清场对称——源 _smoke_save 同款) + DirAccess.remove_absolute(path9) + + +# ---- ④ 试玩记录仪(源 `_smoke_playtest_logger` 整套八条,主会话裁决七改判集成—— +# PlaytestLogger 挂真实结算流:会话文件/逐结算快照/字段齐备/spawn_count 回归/诊断转储/JSONL 回读)---- +func _smoke_playtest_logger() -> void: + print("--- PlaytestLogger(结算快照 JSONL + 诊断转储)---") + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260912) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var logger = load("res://presentation/playtest_logger.gd").new() + var path: String = logger.start_session(20260912) + logger.attach_battle(settlement) + var snaps: Array = [] + settlement.settlement_snapshot.connect(func(snap): snaps.append(snap)) + Kit.check("会话文件开启(%s)" % path, path.contains("playtest_20260912")) + Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) + var minesweeper = settlement.minesweeper + for i in range(2): + minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("每结算一快照(%d 次结算 → %d 快照)" % [2, snaps.size()], snaps.size() == 2) + var s1: Dictionary = snaps[0] + var required := ["kind", "level", "depth", "round", "verdict", "hero", "economy", "board", "enemies", "buffs", "round_facts", "spawn_count"] + var missing := [] + for k in required: + if not s1.has(k): + missing.append(k) + Kit.check("快照字段齐备(缺 %s)" % str(missing), missing.is_empty()) + Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq")) + # spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→ + # unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位 + Kit.check("快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))], + int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2))) + logger.log_action("dig", Vector2i(1, 1), true) + var diag: String = logger.dump_diagnosis({"test": true}) + Kit.check("诊断转储文件生成(%s)" % diag, diag.contains("diag_")) + # JSONL 文件内容验证(重新读取) + var f := FileAccess.open(path, FileAccess.READ) + var lines := 0 + var parse_ok := true + while not f.eof_reached(): + var line := f.get_line() + if line.strip_edges() == "": + continue + lines += 1 + var parsed = JSON.parse_string(line) + if parsed == null: + parse_ok = false + Kit.check("JSONL 全部行可解析(%d 行 = session + 2 结算)" % lines, lines >= 3 and parse_ok) + logger.close_session() diff --git a/game/tests/integration/node_map_templates_test.gd b/game/tests/integration/node_map_templates_test.gd new file mode 100644 index 00000000..839fc9c0 --- /dev/null +++ b/game/tests/integration/node_map_templates_test.gd @@ -0,0 +1,107 @@ +# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·模板池 +# 断言面 = 模板池套(批 B 断言迁移自 smoke_all.gd `_smoke_node_map_templates`,G12 施工组; +# 主会话裁决六整套集成不拆——跨四系统且走落盘)。 +# 跨系统面 = RngService(node_map 用途标签/抽签确定性/零消耗计账)× +# LevelRunner(start_level_external 受理/模板 0 回滚档)× +# AdventureRunner(first_script 全关零抽签/逐关恰一抽签)× +# SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。 +# 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由断言落)。 +# 运行:godot --headless --path game --script res://tests/integration/node_map_templates_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") + +var _db + + +func _init() -> void: + _db = load("res://core/config_db.gd").new() + # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[node_map_templates_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + _smoke_node_map_templates() + quit(Kit.finish("node_map_templates_test")) + + +# ---- 关卡节点图生成·模板池冒烟(v0.4 §P0.6 断言面)---- +func _smoke_node_map_templates() -> void: + # ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志) + var svc = load("res://core/rng_service.gd").new() + Kit.check("node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)", + (svc.PURPOSE_TAGS as Array).has("node_map")) + # ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员) + for li in range(1, 4): + var row := {} + for r in _db.level.rows: + if int(r.level_index) == li: + row = r + var tpls: Array = row.get("templates", []) + Kit.check("L%d 模板池 = 5 张(拍板点④——首版池规模)" % li, tpls.size() == 5) + Kit.check("L%d 模板 0 = 现行固定图(回滚档成员)" % li, + not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)")) + # ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费 + var rng0 = load("res://core/rng_service.gd").new() + rng0.new_adventure(910001) + var runner0 = load("res://core/level_runner.gd").new(_db, rng0) + var c_before: int = rng0.counter() + Kit.check("强制 template_pick=0 起关受理", runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # start_level_external 签名收口(bonus 参数由 RunLedger 单类下传) + Callable(self, "_smoke_rest_stub"), {}, true, 0)) + Kit.check("强制取模板零 RNG 消耗(零抽签——首次脚本语义)", rng0.counter() == c_before) + var tpl0: Dictionary = {} + for r in _db.level.rows: + if int(r.level_index) == 2: + tpl0 = r.get("templates", [])[0] + Kit.check("强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)", + runner0.level_row.get("nodes", []) == tpl0.get("nodes", [])) + # ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图) + var names := [] + for i in range(2): + var rg = load("res://core/rng_service.gd").new() + rg.new_adventure(910002) + var rn = load("res://core/level_runner.gd").new(_db, rg) + rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, + Callable(self, "_smoke_rest_stub"), {}, true, -1) + names.append(rn.active_template_name) + Kit.check("抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]), + names[0] == names[1] and String(names[0]) != "") + # ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档) + var lt = load("res://data/schemas/level_table.gd").new() + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, + "templates": [tpl0]}]) + Kit.check("N=1 池表通过图校验(回滚档形态合法)", lt.validate().is_empty()) + # ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异) + var rngF = load("res://core/rng_service.gd").new() + rngF.new_adventure(910003) + var adrF = load("res://core/adventure_runner.gd").new(_db, rngF) + adrF.start_adventure(910003, {"first_script": true}) + Kit.check("首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)", + String(adrF.runner().active_template_name).ends_with("(现行)")) + var cF: int = rngF.counter() + var rngT = load("res://core/rng_service.gd").new() + rngT.new_adventure(910003) + var adrT = load("res://core/adventure_runner.gd").new(_db, rngT) + adrT.start_adventure(910003, {"first_script": false}) + Kit.check("抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)", rngT.counter() == cF + 1) + Kit.check("抽签模式 active_template_name 有值(MC 分层出数取数面在位)", + String(adrT.runner().active_template_name) != "") + # ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等) + DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删 + var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json") + sv.load_from_disk() + Kit.check("新档首次冒险布尔位 = false(默认域)", sv.is_first_adventure_done() == false) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"}) + Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true) + mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"}) + Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true) + + +# ---- 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位; +# smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)---- +func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int: + return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index a2ee7b20..c6d447f8 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -48,6 +48,8 @@ func _suites() -> Array: {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 秘宝双表校验 {"path": "res://tests/unit/data_config/counter_prefix_fatal_test.gd", "expect": 1, "negative": true}, # 卡② V9 前缀合法性+引用闭合加载致命 {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, + {"path": "res://tests/unit/data_config/table_structure_guard_test.gd", "expect": 0}, # 表结构守卫宿主(adventure 配额与同调目录/enemy 带键行序/AC 单轨与掷点参数/variation 门控互锁/difficulty 修饰/reward 九型/meta 行族) + {"path": "res://tests/unit/data_config/rng_stream_boot_test.gd", "expect": 0}, # 启动初始化与统一 RNG 流宿主(data/tables 加载链/统一流黄金向量 m=42/derive_at 恒等/open_stream 取号序/new_adventure 计数器重置——批 B 自 smoke_all 迁入;源 :70 事件日志落盘条按裁决一转办集成宿主) {"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面 {"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点 {"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口) @@ -58,8 +60,13 @@ func _suites() -> Array: {"path": "res://tests/unit/presentation/forge_workbench_test.gd", "expect": 0}, # 工作间两区六路线 UI 直验(V13 自动化前置面) {"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0}, + {"path": "res://tests/unit/battle_settlement/roll_multiplier_test.gd", "expect": 0}, # 掷点与乘区宿主(敌攻击类型轴/护甲掷点行为面/乘区接线/伤害类型克制与标错/fresh 池攻击链——批 B 自 smoke_all 迁入) + {"path": "res://tests/unit/battle_settlement/seal_polarity_test.gd", "expect": 0}, # 封印与极性宿主(末点 AP 封印/封印不可逆四情况/清图奖励双份与弱形式/刷新 provenance 守卫/极性路由与封印溢价/负向字典/格面资源资格——批 B 自 smoke_all 迁入) + {"path": "res://tests/unit/battle_settlement/round_flow_skill_pool_test.gd", "expect": 0}, # 轮内流与技能池宿主(全战斗回放/轮内刷新与 V5 快照单源/遭遇区间骰/结算五段全序/技能池数据面·回放独立·EV 锚·死亡打断·校验拒绝/妨害附加/四桶计数——批 B 自 smoke_all 迁入) + {"path": "res://tests/unit/battle_settlement/variant_placement_test.gd", "expect": 0}, # 变异布置宿主(战内布置段门控与数量公式/全局变异兜底回落与资源面偏斜——批 B 自 smoke_all 迁入) {"path": "res://tests/unit/battle_settlement/skill_unlock_test.gd", "expect": 0}, # Boss 半血解锁技·容器机制(门谓词/闩不可逆/双面过滤/保底不逼锁定/零扰动) {"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0}, + {"path": "res://tests/unit/contracts/source_guard_test.gd", "expect": 0}, # 源码契约宿主(project.godot 警告配置与类型闸/产品码卫生死代码·影子账·层序单口——批 B 自 smoke_all 迁入;被测对象=仓库自身源文件,裁决五新设) {"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0}, {"path": "res://tests/unit/presentation/terrain_tile_sets_test.gd", "expect": 0}, # 提灯载体行不在册(呈现桥断言归阔视野带参数面) @@ -76,14 +83,22 @@ func _suites() -> Array: {"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗) # ---- L2 集成层(跨系统用例,与 unit/ 的隔离断言面分工)---- {"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性) + {"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·模板池(模板池跨系统×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆) + {"path": "res://tests/integration/cross_system_flow_test.gd", "expect": 0}, # 跨系统真实流×落盘(RNG 事件日志落盘/专长断点往返/存档摘要集成/试玩记录仪真实结算流——批 B 转办块宿主,裁决一/二/三/七) # ---- core 四件与呈现层两件的直接单测 ---- {"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽) + {"path": "res://tests/unit/core/board_gen_reward_test.gd", "expect": 0}, # 棋盘生成与地图战斗资源宿主(格边长公式/资源身份面/生成器三分出口与 r0 闸/伤害量值区间——批 B 自 smoke_all 迁入) + {"path": "res://tests/unit/core/adventure_feats_test.gd", "expect": 0}, # 冒险结构与专长宿主(状态机受理面/三关序列与账本封闭/败北线/feat_table 四级/专长凭证保底/V3 订阅面逐件——批 B 自 smoke_all 迁入;断点往返三条按裁决二转办集成宿主) + {"path": "res://tests/unit/core/level_shop_economy_test.gd", "expect": 0}, # 关卡图与 GP 商店宿主(level 图校验与推进/enemy 行集定点/钱包到账/escrow 钳制/商店三收口/共享池抽买——批 B 自 smoke_all 迁入) + {"path": "res://tests/unit/core/meta_progression_forge_test.gd", "expect": 0}, # 元进度与锻造宿主(锻造两轴/树购买受理面/hero_override 注入/收束入账与工坊解锁/树六键合成——批 B 自 smoke_all 迁入) + {"path": "res://tests/unit/core/save_manager_domain_test.gd", "expect": 0}, # 存档域宿主(读写往返/迁移链/损坏兜底/域界干净/摘要 cap/断点域七字段/三槽位承载——批 B 自 smoke_all 迁入;摘要集成块按裁决三转办集成宿主) {"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}, # 战斗叙述(数值带入/胜负互异/收束态恒定) {"path": "res://tests/unit/presentation/type_glow_test.gd", "expect": 0}, # 未开格类型微光数据面(九型归组封闭/清单外显式失败/冻结色相只准动明度/α170·带宽cell÷8锚定/合成空间阶梯单调+模型CR≥1.20/probe API 形状) + {"path": "res://tests/unit/presentation/theme_projection_test.gd", "expect": 0}, # 呈现投影与主题宿主(微光数据面/战斗日志行协议/全局主题与按钮五态/纹理过滤白名单/对比度墨色锚/元素细节源码锚/诊断钮退役源码契约/状态面板与生涯页只读投影——批 B 自 smoke_all 迁入;试玩记录仪整套按裁决七转办集成宿主) ] diff --git a/game/tests/smoke_boot.gd b/game/tests/smoke_boot.gd new file mode 100644 index 00000000..d3d5172c --- /dev/null +++ b/game/tests/smoke_boot.gd @@ -0,0 +1,77 @@ +# tests/smoke_boot.gd — 冒烟测试:极小启动场景(这版活着吗——零逻辑判定) +# 以项目运行目标拉起=真实开机路径:autoload 四件注册在位、配置表全载(fail-fast 通道)、 +# 全局字体与主题可加载、主屏场景实例化、启动态菜单可见。 +# 断言只查存在性与可见性,不做任何玩法逻辑判定;断言完打印摘要并以退出码收口, +# 判定三闸(退出码+摘要失败数+脚本错误扫描)由外部运行器 tools/run_smoke.py 承担。 +# 运行:python tools/run_smoke.py(本地闸 00 冒烟测试) +extends Node + +const MAIN_SCENE_PATH: String = "res://presentation/main.tscn" +const FONT_PATH: String = "res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf" +const THEME_PATH: String = "res://assets/theme/main_theme.tres" +const BOOT_FRAMES: int = 4 # 主屏装配是逐帧推进的挂树过程,等满再断言(探针族同惯例) + +var _failures: int = 0 +var _checks: int = 0 + + +func check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" [PASS] " + label) + else: + _failures += 1 + print(" [FAIL] " + label) + + +func _ready() -> void: + _check_autoloads() + var main: Node = await _boot_main_scene() + _check_main_scene(main) + print("[smoke] 摘要:%d 项检查,%d 项失败" % [_checks, _failures]) + get_tree().quit(1 if _failures > 0 else 0) + + +# 开机前置面:autoload 注册与资源可加载(挂主屏之前先查,失败时主屏断言整段跳过) +func _check_autoloads() -> void: + for autoload_name in ["ConfigDB", "RngService", "SaveManager", "MetaProgression"]: + var node: Node = get_node_or_null(NodePath("/root/" + autoload_name)) + check(node != null, "autoload %s 在位" % autoload_name) + var db: Node = get_node_or_null(NodePath("/root/ConfigDB")) + check(db != null and bool(db.get("is_ready")) and db.get("hero") != null, + "ConfigDB 就绪且配置表全载(is_ready+hero 表在座,校验失败会致命中止)") + check(load(MAIN_SCENE_PATH) != null, "主屏场景资源可加载(%s)" % MAIN_SCENE_PATH) + check(load(FONT_PATH) != null, "全局字体资源可加载(%s)" % FONT_PATH) + check(load(THEME_PATH) != null, "全局主题资源可加载(%s)" % THEME_PATH) + + +# 以项目运行目标同款方式实例化主屏(真实开机路径——区别于 --script 模式的无 autoload 裸跑) +func _boot_main_scene() -> Node: + await get_tree().process_frame + var packed: PackedScene = load(MAIN_SCENE_PATH) as PackedScene + if packed == null: + return null + var main: Node = packed.instantiate() + if main == null: + return null + add_child(main) + for i in range(BOOT_FRAMES): + await get_tree().process_frame + return main + + +# 主屏存活面:组合根装配句柄在位(main.X 一级句柄契约,探针挂载假设的启动侧证明) +func _check_main_scene(main: Node) -> void: + check(main != null and main.is_inside_tree(), "主屏场景已实例化并在树") + if main == null: + return + var run_scope: Node = main.get("run_scope") + check(run_scope != null, "闲时会话 run_scope 非空(永非 null 契约)") + check(run_scope != null and run_scope.is_inside_tree(), "闲时会话 run_scope 在树") + var start_overlay: Control = main.get("_start_overlay") + check(start_overlay != null, "开始菜单层已装配(_start_overlay)") + check(start_overlay != null and start_overlay.visible, "启动态菜单可见(boot 后玩家见菜单)") + check(main.get("settings_store") != null, "系统设置存储已装配(settings_store)") + check(main.get("debug_badge") != null, "调试入口角标已装配(debug_badge)") + check(main.get("legend_overlay") != null, "图例覆盖层已装配(legend_overlay)") + check(main.get("status_panel") != null, "局内状态面板已装配(status_panel)") diff --git a/game/tests/smoke_boot.gd.uid b/game/tests/smoke_boot.gd.uid new file mode 100644 index 00000000..b33e90d4 --- /dev/null +++ b/game/tests/smoke_boot.gd.uid @@ -0,0 +1 @@ +uid://m7p3g1baece4 diff --git a/game/tests/smoke_boot.tscn b/game/tests/smoke_boot.tscn new file mode 100644 index 00000000..aceb75a9 --- /dev/null +++ b/game/tests/smoke_boot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://csmokeboot01"] + +[ext_resource type="Script" path="res://tests/smoke_boot.gd" id="1"] + +[node name="SmokeBoot" type="Node"] +script = ExtResource("1") diff --git a/game/tests/static_scan.sh b/game/tests/static_scan.sh index 0c87d6a2..62c66885 100644 --- a/game/tests/static_scan.sh +++ b/game/tests/static_scan.sh @@ -96,6 +96,9 @@ fi # ── S11 — 代码注释禁用词 ── # 禁「板级」「门票」「正主」「落籍」「完美排雷」等退役词进注释面(完整词表见术语表解析) # 权威出处:design/gdd/术语表.md「退役词对照」节+「对外命名纪律」节 +# 同形异义豁免:机制词「去重闸」(封印重判去重的玩法判别器)与某闸档退役词后二字同形—— +# 行内含「去重闸」即整行跳过(2026-10-22 测试术语重构批随闸档用语入表增设,否则 smoke_all.gd +# 在册断言标签成片误红;代价=该行同时出现的真闸档用词也不报,靠文档域 08 闸兜住) # 扫描面:game/ 全域 .gd/.tres/.md/.json/.sh(.txt 不收=第三方许可证非本仓库文本) # 注释文本提取:.gd/.sh 取 # 后文本;.tres 取 ; 后文本;.md 取整行;.json 无注释语法(保留扩展名但不产生注释段) # 三层豁免依序:①闸门自指(行内含检查/禁令形态即跳过)②显式留档豁免单 4 处 ③非注释面天然不取 @@ -145,6 +148,7 @@ else for(i=1;i<=nw;i++){ if(index(cmt,words[i])>0){ if($0~/contains\(|find\(|grep|has_char\(|禁「|禁『|零「|零『/)next + if(index(cmt,"去重闸")>0)next if(FILENAME~"enemy.tres"&&FNR==653)next if(FILENAME~"level_runner"&&FNR==832)next if(FILENAME~"adventure_runner"&&FNR==403)next @@ -164,6 +168,7 @@ else for(i=1;i<=nw;i++){ if(index(cmt,words[i])>0){ if($0~/contains\(|find\(|grep|has_char\(|禁「|禁『|零「|零『/)next + if(index(cmt,"去重闸")>0)next if(FILENAME~"enemy.tres"&&FNR==653)next print FILENAME":"FNR":"words[i] next @@ -179,6 +184,7 @@ else for(i=1;i<=nw;i++){ if(index($0,words[i])>0){ if($0~/contains\(|find\(|grep|has_char\(|禁「|禁『|零「|零『/)next + if(index($0,"去重闸")>0)next print FILENAME":"FNR":"words[i] next } diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd new file mode 100644 index 00000000..ee9c9971 --- /dev/null +++ b/game/tests/system_test.gd @@ -0,0 +1,564 @@ +# tests/system_test.gd — 系统测试宿主薄壳(SceneTree+构造注入) +# 系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一,闸 01)。 +# 本件现宿主零断言——smoke_all.gd 的断言按「只剪切禁改写」纪律迁入(断言迁移批执行, +# 本批只落宿主薄壳,跑通即绿)。迁移完成后 tools/run_system_test.py 缺省 --script 改指本件。 +# 协议照单元侧惯例:extends SceneTree 获得 quit(code);--script 模式不加载 autoload, +# 运行形态=--script 直跑+autoload 分派闸(ConfigDB/SaveManager 在座才进主断言面——闸 01 实跑 65 项 0 失败实证在案);摘要行由 tools/run_system_test.py 三闸判定 +# (退出码+摘要失败数+脚本错误扫描)。 +# 运行:python tools/run_system_test.py --script res://tests/system_test.gd +extends SceneTree + +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(lifecycle 活战斗驱动;smoke 库件迁移期按现路径 preload) + +var _failures: int = 0 +var _checks: int = 0 + + +func check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" [PASS] " + label) + else: + _failures += 1 + print(" [FAIL] " + label) + + +func _initialize() -> void: + print("=== Dungeon Sweeper 系统测试(宿主薄壳——断言面待迁移批自 smoke_all.gd 迁入) ===") + _split_tail() + + +# ---- 场景拆分/会话物化异步尾段(autoload 挂载需过 _initialize 后首帧〔run_all.gd 头注时序〕, +# 挂 main.tscn 的断言在帧后执行;摘要打印与 quit 在本尾段)---- +# (自 game/smoke/smoke_all.gd 剪切迁入——断言 label 逐字原样;G12 两套调用不随迁, +# 归 source_guard_test.gd,断言迁移批 B G1) +func _split_tail() -> void: + await process_frame + await process_frame + var autoload_ok: bool = get_root().get_node_or_null("/root/ConfigDB") != null and get_root().get_node_or_null("/root/SaveManager") != null + if autoload_ok: + await _smoke_scene_split() # 协程函数必须 await(无 await=发射后不管,首个 await 后即被 quit 截断) + await _smoke_boot_chain() + await _smoke_run_lifecycle() + await _smoke_lifecycle_matrix() + else: + check(false, "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)") + print("[system_test] 摘要:%d 项检查,%d 项失败" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) + + +# ---- 三屏场景化断言(V2——独立实例化+关键节点在位+组合根树序/z 序保序)---- +# (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) +func _smoke_scene_split() -> void: + print("--- 迭代27 T1 三屏场景化(V2:屏壳独立实例化+组合根装配保序)---") + # ① 三屏壳独立实例化(F6 等价——headless 直挂;视图代码构建在屏壳 _ready) + var menu: Node = load("res://presentation/scenes/menu_screen.tscn").instantiate() + root.add_child(menu) + await process_frame + check(menu.start_overlay != null and menu.entersavebtn != null and menu.settingsbtn != null and menu.quitbtn != null, + "V2 菜单屏壳独立实例化(start_overlay+操作组三钮在位——主菜单三入口化)〔卡② mn1〕") + check(menu.start_overlay.visible, "V2 菜单层默认可见(非模态全屏态)") + check(menu.mouse_filter == Control.MOUSE_FILTER_IGNORE, "V2 屏壳根零鼠标截获(IGNORE——全屏 STOP 壳吃输入病灶回归闸)") + menu.queue_free() + var camp: Node = load("res://presentation/scenes/camp_screen.tscn").instantiate() + root.add_child(camp) + await process_frame + check(camp.camp_overlay != null, "V2 营地屏壳独立实例化(camp_overlay 在位)") + check(not camp.camp_overlay.visible, "V2 营地层默认隐藏(模态纪律)") + camp.queue_free() + var adv: Node = load("res://presentation/scenes/adventure_screen.tscn").instantiate() + root.add_child(adv) + await process_frame + check(adv.battle_root != null and adv.stage != null and adv.board_zone != null and adv.board_view != null and adv.ap_bar != null and adv.level_screen != null, + "V2 冒险屏壳独立实例化(战斗宿主+四区句柄+关卡地图屏在位)") + adv.queue_free() + await process_frame + # ② 组合根装配(合同单点化——LAYER_CONTRACT 唯一事实源:树序=登记序=z 序同源派生) + var main: Node = load("res://presentation/main.tscn").instantiate() + root.add_child(main) + await process_frame + # V1a 启动态顶层断言:boot 后自树顶向下第一个可见内容层=start_overlay(headless 可判) + #(内容层遍历——屏壳是容器不当候选:camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历会误命中) + var top: Node = _topmost_visible_layer(main) + check(top == main._start_overlay, + "V1a 启动态顶层可见层=start_overlay(实测顶层=%s——菜单被冒险屏盖住即红)〔装配生命批 V1〕" + % (top.name if top != null else "null")) + # V1b 语义关系:start_overlay 在冒险屏内容之上(菜单屏壳在冒险屏壳之上——装配序修复判据) + check(main._menu_screen.get_index() > main._adventure_screen.get_index(), + "V1b 菜单屏壳在冒险屏壳之上(start_overlay 盖住战斗内容)〔装配生命批 V1〕") + # V1c 合同树序链逐对保序(直接子层全链=屏壳/模态/全屏态/非视觉节点;宿住层树序随宿主不参与) + var chain_detail := "" + var chain_prev: Node = null + var chain_ok := true + for entry: Dictionary in main.LAYER_CONTRACT: + if entry.has("host"): + continue + var node: Node = main._layer_node(String(entry["shell"]) if entry.has("shell") else String(entry["key"])) + if node == null: + chain_ok = false + chain_detail += " 缺失:%s" % String(entry["key"]) + continue + if chain_prev != null and node.get_index() <= chain_prev.get_index(): + chain_ok = false + chain_detail += " %s(%d)≤%s(%d)" % [chain_prev.name, chain_prev.get_index(), node.name, node.get_index()] + chain_prev = node + check(chain_ok, "V1c 合同树序链逐对保序(运行期树序==LAYER_CONTRACT 序;违例:%s)〔装配生命批 V1〕" % chain_detail) + # ③ 探针挂载假设句柄在座(main.X 一级路径——探针迁移面收口证明) + check(main._start_overlay == main._menu_screen.start_overlay and main.camp_overlay == main._camp_screen.camp_overlay and main.level_screen == main._adventure_screen.level_screen and main._battle_root == main._adventure_screen.battle_root and main.board_view == main._adventure_screen.board_view, + "V2 组合根句柄指向屏壳成员(探针挂载假设 main.X 零迁移)") + check(main.run_scope != null and not main.run_scope.is_adventure, + "V2 闲时会话自 _ready 即在(永非 null;迭代 27 T3——gp 影子账退役无账本成员)") + main.queue_free() + await process_frame + + +# 顶层可见内容层(V1a 用——候选=合同模态/全屏态+两屏壳的 boot 内容成员;屏壳本身是容器不当候选: +# camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历自顶向下会先命中透明壳) +func _topmost_visible_layer(main: Node) -> Node: + var candidates: Array[Node] = [main._start_overlay, main._battle_root, main._inter_overlay, + main.legend_overlay, main.node_overlay, main.level_screen, main.curio_overlay, + main._end_overlay, main.shop_overlay, main.camp_overlay, main.settings_overlay, + main.status_panel, main.career_overlay, main.confirm_dialog, main.debug_overlay] + var best: Node = null + var best_x := -1 + var best_y := -1 + for c: Node in candidates: + if c == null or not (c is CanvasItem) or not (c as CanvasItem).visible: + continue + var rx: int = c.get_parent().get_index() + var ry: int = c.get_index() + if best == null or rx > best_x or (rx == best_x and ry > best_y): + best = c + best_x = rx + best_y = ry + return best + + +# ---- boot 链路时序后置条件(启动态六断言;只立行为可判断言,不造假时序)---- +# (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) + +func _count_visible_buttons(node: Node) -> int: + var n := 1 if (node is Button and node.visible) else 0 + for c in node.get_children(): + n += _count_visible_buttons(c) + return n + + +# ---- boot 链路时序后置条件(启动态六断言;只立行为可判断言,不造假时序)---- +func _smoke_boot_chain() -> void: + print("--- 迭代28 T2 boot 链路时序后置条件(V2:六断言)---") + var main: Node = load("res://presentation/main.tscn").instantiate() + root.add_child(main) + await process_frame + await process_frame + # ① ConfigDB 就绪且表已载 + var db = main.get_node("/root/ConfigDB") + check(db.is_ready and db.hero != null, + "V2a ConfigDB 就绪且表已载(is_ready+hero 表在座)〔装配生命批 V2〕") + # ② 设置已应用(引擎态=settings_store 现值——真实设置文件照读,一致性判据与文件内容无关) + var ss = main.settings_store + var master := AudioServer.get_bus_index("Master") + check(AudioServer.is_bus_mute(master) == (ss.volume_linear <= 0.001) + and is_equal_approx(AudioServer.get_bus_volume_db(master), linear_to_db(maxf(ss.volume_linear, 0.001))), + "V2b 设置已应用·音量(Master mute/volume_db=settings_store 现值 linear=%f)〔装配生命批 V2〕" % ss.volume_linear) + var ui_kit = load("res://presentation/views/ui_kit.gd") + check(ui_kit.motion_scale == (0.5 if ss.reduced_motion else 1.0), + "V2b 设置已应用·减动效(UiKit.motion_scale=%s=settings_store.reduced_motion=%s)〔装配生命批 V2〕" % [ + str(ui_kit.motion_scale), str(ss.reduced_motion)]) + # ③ 启动态菜单可见 + check(main._start_overlay.visible, + "V2c 启动态 _start_overlay.visible=true(boot 后玩家见菜单)〔装配生命批 V2〕") + # ④ 续玩钮可见态==SaveManager 断点在档态 + var sm = main.get_node("/root/SaveManager") + # ④-1 boot 路径:启动态钉死(卡① sv2)——boot 无激活态、断点在档恒否,续玩钮恒隐 + #(原「按钮态==真实档断点态」关系判据随启动态换代改写:test-conventions §9.5 申报在案,卡② mn8 再承接) + check(not sm.has_breakpoint(), + "V2d 续玩钮族退役·boot 无断点在档(启动态不隐式激活——卡① sv2 钉死;恒隐判据随钮退役由本断言+V2d 三钮在位承接)〔装配生命批 V2·卡② mn8 改写〕") + # ④-1b 启动态三断言+旧单档迁移不变式(卡① sv2/sv12——真实 autoload boot 链) + check(sm.active_slot() == 0 and not sm.has_write_target(), + "V2-s1 boot 后无激活态(active_slot=0、写盘目标空)〔卡① sv2〕") + check(sm.keystone_balance() == 0 and not sm.has_breakpoint() and sm.adventure_total() == 0, + "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕") + check(not sm.write_to_disk(), + "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕") + check(not (FileAccess.file_exists("user://save.json") and not FileAccess.file_exists(sm.slot_path(1))), + "V2-s4 旧单档迁移不变式(旧档在则槽 1 必在——Q1 甲一次性迁移已发生)〔卡① sv12〕") + # ④-2 三钮承接(卡② mn8——V2d 续玩钮族随钮退役废止:钮可见性联动面由卡① V2-s 启动态断言与 + # sv11 attach 语义段承接;attach 原地重置语义保持——绝不碰真实档 user://save.json) + for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + sm.attach_path("user://smoke_boot_chain.json") + check(main._menu_screen.entersavebtn != null and main._menu_screen.settingsbtn != null + and main._menu_screen.quitbtn != null, + "V2d 三钮在位·进入存档/设置/退出游戏(续玩钮族退役后主菜单操作组承接)〔卡② mn1/mn8〕") + for old_btn in ["lvbtn", "resumebtn", "campbtn", "careerbtn"]: + check(not (old_btn in main._menu_screen), + "V2d 四旧钮句柄零命中·%s(彻底撤出——禁隐藏态禁置灰态残留)〔卡② mn2〕" % old_btn) + check(_count_visible_buttons(main._start_overlay) == 3, + "V2d 渲染面可见按钮计数==3(旧钮接回即红——渲染面锚,句柄缝盲区闭合)〔卡② mn1/mn2〕") + # ⑤ 闲时会话(_ready 即建、永非 null、在树) + check(main.run_scope != null and not main.run_scope.is_adventure and main.run_scope.is_inside_tree(), + "V2e run_scope 非空且为闲时会话(is_adventure=false+is_inside_tree)〔装配生命批 V2〕") + # ⑥ 调试浮窗 boot 不开+菜单角标在(菜单优先——boot 不自动开浮窗, + # 入口=开始屏右下角「调试」角标+对局中两屏角标) + check(not main.debug_overlay.visible, + "V2f 调试浮窗 boot 不自动开(菜单优先)〔2026-10-16 用户令〕") + check(main.debug_badge != null and main.debug_badge.visible, + "V2f 调试浮层角标 boot 可见(全局浮层入口在座)〔2026-10-16 用户令〕") + main.queue_free() + await process_frame + for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + +# ---- 生命周期存活性矩阵(九阶段×五引用面;驱动复用 V3 debug seed 开局路径+ +# _auto_play_round 活战斗模式+ui_struct resume 夹具模式,不另起点击链)---- +# 阶段映射说明:计划书十阶段中「开局停点」与「地图选路」在本现行流程同为关卡画面停点态 +#(level_started→choices_presented 同步到达,无可观测中间态)——合并为 V3b 一处断言。 +func _smoke_run_lifecycle() -> void: + print("--- 迭代27 T1 会话物化(V3:两连局复位+脏状态不残留)---") + var main: Node = load("res://presentation/main.tscn").instantiate() + root.add_child(main) + await process_frame + # 存档隔离(autoload attach 原地重置——绝不碰真实档 user://save.json) + var sm = main.get_node("/root/SaveManager") + for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + sm.attach_path("user://smoke_run_lifecycle.json") + sm.mark_first_adventure_done() + # ---- 第一局:开局→注脏→放弃→终局出口→会话收束 ---- + main.debug_overlay.seed_edit.text = "42" + main._start_adventure_from_menu() + await process_frame + check(main.run_scope != null and main.run_scope.is_adventure and main.run_scope.adventure != null, + "V3 开局建会话(is_adventure+adventure 在座)") + check(main.run_scope.hints_shown.is_empty(), + "V3 新会话局态零值") + # 注入脏状态(典型泄漏源——新会话必须整体吞掉;刷新计数单源化在编排器,不属会话脏面) + main.run_scope.hints_shown["chord"] = true + main.run_scope.boss_active = true + main.run_scope.last_gain_amount = 77 + var finished := {"done": false} + main.run_scope.adventure.adventure_finished.connect(func(_s: Dictionary) -> void: + finished.done = true, CONNECT_ONE_SHOT) + check(main.run_scope.adventure.abandon(), "V3 放弃受理(入口停点天然非战斗期)") + for i in range(8): + if finished.done: + break + await process_frame + check(finished.done, "V3 冒险终局到达(abandon→adventure_finished 同步链)") + check(main._end_overlay.is_open(), "V3 终局面板弹起") + main._on_battle_end_continue() # 终局出口(回菜单+会话收束——手工置空清单已消亡) + await process_frame + check(not main.run_scope.is_adventure and main.run_scope.adventure == null and main.run_scope.runner == null, + "V3 会话收束(新闲时会话零冒险/关卡对象)") + check(main.run_scope.hints_shown.is_empty() and not main.run_scope.boss_active and main.run_scope.last_gain_amount == 0, + "V3 局态封印/账本全复位(queue_free 物化——注入脏状态零残留)") + check(main._start_overlay.visible, "V3 回开始菜单(终局出口路径)") + # ---- 第二局:放弃终局无断点残留→直进开局→全新会话 ---- + check(not sm.has_breakpoint(), "V3 放弃终局清断点(无残留断点——续玩按钮态联动)") + main.debug_overlay.seed_edit.text = "43" + main._start_adventure_from_menu() + await process_frame + check(main.run_scope.is_adventure and main.run_scope.adventure != null and main.run_scope.hints_shown.is_empty(), + "V3 第二连局全新会话(两连局零泄漏)") + check(not main._start_overlay.visible, "V3 直进入局(菜单隐)") + main.queue_free() + await process_frame + for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + +# 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采) +# (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) +func _face_snapshot(main: Node) -> Dictionary: + var tu = main._treasure_ui + var bo = main._bonus + var cp = main._camp_pres + var mf = main._modal_flow + var rp = main._row_pacer + return { + "treasure_settlement": tu._settlement_get.call(), + "treasure_runner": tu._runner_get.call(), + "bonus_meta": bo._meta_get.call(), + "bonus_save": bo._save_get.call(), + "bonus_adventure": bo._adventure_get.call(), + "bonus_runner": bo._runner_get.call(), + "bonus_mode": bo._adventure_mode_get.call(), + "camp_meta": cp._meta_get.call(), + "camp_save": cp._save_get.call(), + "camp_runner": cp._runner_get.call(), + "mf_start": mf.start_visible_get.call(), + "mf_inter": mf.inter_visible_get.call(), + "mf_settlement": mf.settlement_get.call(), + "mf_rows_empty": mf.rows_empty_get.call(), + "rp_log": rp.log_view, "rp_stage": rp.stage, "rp_board_zone": rp.board_zone, + "rp_fx": rp.settlement_fx, "rp_board": rp.board_view, + "rp_refresh": rp.refresh_request, "rp_pump": rp.pump_ended, + "rs_adventure": main.run_scope.adventure, "rs_runner": main.run_scope.runner, + "rs_settlement": main.run_scope.settlement, "rs_is_adventure": main.run_scope.is_adventure, + } + + +# 阶段面断言:adv/run/set=alive|null|both(会话三面存活态);恒活面每阶段必查; +# 现值回调与 run_scope 成员必须同源(回调链无悬挂铁证);extra=阶段附加判据。 +func _stage_faces(main: Node, tag: String, adv: String, run: String, set: String, extra: Dictionary = {}) -> void: + var snap := _face_snapshot(main) + var bad := "" + for k in ["bonus_meta", "bonus_save", "camp_meta", "camp_save", "rp_log", "rp_stage", "rp_board_zone", "rp_fx", "rp_board"]: + if not is_instance_valid(snap[k]): + bad += " %s=失效" % k + for k in ["rp_refresh", "rp_pump"]: + var cb: Variant = snap[k] + if not (cb is Callable) or not (cb as Callable).is_valid(): + bad += " %s=非有效Callable" % k + if not main.run_scope.is_inside_tree(): + bad += " run_scope=不在树" + bad += _face_liveness("adventure", snap["rs_adventure"], adv) + bad += _face_liveness("runner", snap["rs_runner"], run) + bad += _face_liveness("settlement", snap["rs_settlement"], set) + if snap["treasure_settlement"] != snap["rs_settlement"]: + bad += " treasure._settlement_get≠run_scope.settlement(回调悬挂)" + if snap["treasure_runner"] != snap["rs_runner"]: + bad += " treasure._runner_get≠run_scope.runner(回调悬挂)" + if snap["bonus_adventure"] != snap["rs_adventure"]: + bad += " bonus._adventure_get≠run_scope.adventure(回调悬挂)" + if snap["bonus_runner"] != snap["rs_runner"]: + bad += " bonus._runner_get≠run_scope.runner(回调悬挂)" + if snap["camp_runner"] != snap["rs_runner"]: + bad += " camp._runner_get≠run_scope.runner(回调悬挂)" + if snap["mf_settlement"] != snap["rs_settlement"]: + bad += " modal_flow.settlement_get≠run_scope.settlement(回调悬挂)" + if bool(snap["bonus_mode"]) != bool(snap["rs_is_adventure"]): + bad += " bonus._adventure_mode_get≠run_scope.is_adventure(回调悬挂)" + for k in extra: + match String(k): + "mf_start": + if bool(snap["mf_start"]) != bool(extra[k]): + bad += " modal_flow.start_visible_get 应=%s" % str(extra[k]) + "mf_inter": + if bool(snap["mf_inter"]) != bool(extra[k]): + bad += " modal_flow.inter_visible_get 应=%s" % str(extra[k]) + "mf_rows_empty": + if bool(snap["mf_rows_empty"]) != bool(extra[k]): + bad += " modal_flow.rows_empty_get 应=%s" % str(extra[k]) + "level_vis": + if main.level_screen.visible != bool(extra[k]): + bad += " level_screen.visible 应=%s" % str(extra[k]) + "end_open": + if main._end_overlay.is_open() != bool(extra[k]): + bad += " _end_overlay.is_open 应=%s" % str(extra[k]) + "start_vis": + if main._start_overlay.visible != bool(extra[k]): + bad += " _start_overlay.visible 应=%s" % str(extra[k]) + check(bad.is_empty(), "%s(引用面违例:%s)〔装配生命批 V3〕" % [tag, bad]) + + +# 单面存活态判据:want=null=必须缺位/alive=必须有效非空/both=两可但非空必有效 +func _face_liveness(name: String, v, want: String) -> String: + if want == "null": + return "" if v == null else " %s 应缺位(实测非空)" % name + if v != null and not is_instance_valid(v): + return " %s 悬挂(失效对象)" % name + return "" + + +# ---- 生命周期存活性矩阵(九阶段×五引用面;驱动复用 V3 debug seed 开局路径+ +# _auto_play_round 活战斗模式+ui_struct resume 夹具模式,不另起点击链)---- +# 阶段映射说明:计划书十阶段中「开局停点」与「地图选路」在本现行流程同为关卡画面停点态 +#(level_started→choices_presented 同步到达,无可观测中间态)——合并为 V3b 一处断言。 +# (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) +func _smoke_lifecycle_matrix() -> void: + print("--- 迭代28 T3 生命周期存活性矩阵(V3:九阶段×五引用面)---") + var main: Node = load("res://presentation/main.tscn").instantiate() + root.add_child(main) + await process_frame + await process_frame + var sm = main.get_node("/root/SaveManager") + for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + sm.attach_path("user://smoke_lifecycle.json") + main.debug_overlay.seed_edit.text = "42" + # 阶段①闲时 + await _stage_faces(main, "V3a 闲时", "null", "null", "null", + {"mf_start": true, "mf_inter": false, "mf_rows_empty": true, "start_vis": true}) + # 阶段②开局停点(地图选路停点态) + main._start_adventure_from_menu() + for i in range(6): + await process_frame + await _stage_faces(main, "V3b 开局停点", "alive", "alive", "null", + {"mf_start": false, "level_vis": true}) + # 阶段③地图选路→战斗中(真实选路提交 press_candidate) + main.level_screen.press_candidate(0) + for i in range(10): + await process_frame + await _stage_faces(main, "V3c 战斗中", "alive", "alive", "alive", {"mf_start": false}) + # 阶段④结算后/终局面(AutoPlayer 活战斗驱动到终局——_auto_play_round 同款 phase/round 守卫) + var finished := {"done": false} + main.run_scope.adventure.adventure_finished.connect(func(_s: Dictionary) -> void: + finished.done = true, CONNECT_ONE_SHOT) + var guard := 0 + while main.run_scope.settlement != null and main.run_scope.settlement.is_battle_live() \ + and not finished.done and guard < 600: + var st = main.run_scope.settlement + var ms = st.minesweeper + var ae = st.action_economy + var start_round: int = st.round_index + var round_guard := 400 + while ae.phase == "Active" and st.round_index == start_round and round_guard > 0 \ + and main.run_scope.settlement != null: + round_guard -= 1 + var plan: Dictionary = AutoPlayer.choose_dig(ms) + if plan.is_empty(): + break + if plan.hero_cell != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if ae.phase != "Active" or st.round_index != start_round: + break + ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live(): + break + if ae.phase == "Active" and st.round_index == start_round: + ms.settle_batch([{"action": "stop"}]) + guard += 1 + check(main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live(), + "V3d 活战斗驱动完毕(战斗已收场——%d 轮,finished=%s)〔装配生命批 V3〕" % [guard, str(finished.done)]) + main._flush_row_queue() + for i in range(12): + if main._end_overlay.is_open(): + break + await process_frame + # 分支甲:战斗胜利停点(结算后)→确认进选路→放弃→终局面 + if not finished.done: + await _stage_faces(main, "V3d 结算后(胜利停点)", "alive", "alive", "both", {"mf_start": false, "end_open": true}) + main._on_battle_end_continue() + for i in range(8): + await process_frame + await _stage_faces(main, "V3d2 选路页(停点后)", "alive", "alive", "both", {"mf_start": false, "level_vis": true}) + check(main.run_scope.adventure.abandon(), "V3d2 放弃受理(选路停点天然非战斗期)〔装配生命批 V3〕") + for i in range(12): + if finished.done: + break + await process_frame + main._flush_row_queue() + for i in range(8): + if main._end_overlay.is_open(): + break + await process_frame + # 阶段⑤终局面(败北/放弃皆达) + check(finished.done and main._end_overlay.is_open(), + "V3e 终局面到达(adventure_finished+终局面板开)〔装配生命批 V3〕") + await _stage_faces(main, "V3e 终局面", "alive", "alive", "null", {"mf_start": false, "end_open": true}) + # 阶段⑥回菜单收束(旧局消亡+新会话即驻+无真空窗) + var old_scope = main.run_scope + main._on_battle_end_continue() + check(main.run_scope != old_scope and main.run_scope.is_inside_tree(), + "V3f 收束换会话无真空窗(新闲时会话即刻在树)〔装配生命批 V3〕") + await process_frame + check(not is_instance_valid(old_scope), + "V3f 收束后旧局经 queue_free 消亡(is_instance_valid=false)〔装配生命批 V3〕") + await _stage_faces(main, "V3f 回菜单收束", "null", "null", "null", + {"mf_start": true, "start_vis": true, "end_open": false}) + # 阶段⑦各模态开合后(菜单域四模态真实路径开合——会话面零扰动) + for modal_name in ["settings", "career", "camp", "debug"]: + if modal_name == "settings": + main._open_settings() + elif modal_name == "career": + main._open_career() + elif modal_name == "camp": + # 营地经进档开层(卡② mn9 新路径——campbtn 已撤);槽模板注入隔离命名空间 + #(防碰真实槽 user://save_slot_N.json——真实档红线),营地闭合后恢复生命周期 attach 写盘目标 + #(卡① sv3 无激活拒写——后续阶段断点写盘依赖) + sm.attach_slot_template("user://smoke_lifecycle_slot_%d.json") + main._open_save_select() + for i in range(2): + await process_frame + var ssel_new: Button = main.save_select_overlay._new_btns.get(1) + if ssel_new == null: + check(false, "V3g 营地入口缺失(选档层新档钮驱动中断)〔装配生命批 V3〕") + continue + ssel_new.pressed.emit() # 创建=激活=暂行开营地(卡③ Q3 乙落点) + else: + main._show_debug_overlay() + for i in range(4): + await process_frame + var open_extra: Dictionary = {"mf_start": false} + if modal_name == "debug": + open_extra = {"mf_start": true} # 调试浮窗=浮于菜单之上的 Dialog(不隐藏开始层) + await _stage_faces(main, "V3g 模态开·%s" % modal_name, "null", "null", "null", open_extra) + if modal_name == "settings": + main._on_settings_closed() + elif modal_name == "career": + main._on_career_closed() + elif modal_name == "camp": + main._on_camp_closed() + else: + main._close_debug_overlay() + for i in range(4): + await process_frame + await _stage_faces(main, "V3g 模态合·%s" % modal_name, "null", "null", "null", + {"mf_start": true, "start_vis": true}) + if modal_name == "camp": + main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点) + for i in range(2): + await process_frame + sm.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标 + for suf in ["", ".tmp"]: + var slot_p: String = "user://smoke_lifecycle_slot_1.json" + suf + if FileAccess.file_exists(slot_p): + DirAccess.remove_absolute(slot_p) + # 阶段⑧两连局(第二局全新会话——旧局对象不复用、脏态零残留) + var first_adv = main.run_scope.adventure + main.debug_overlay.seed_edit.text = "43" + main._start_adventure_from_menu() + for i in range(6): + await process_frame + check(main.run_scope.adventure != null and main.run_scope.adventure != first_adv, + "V3h 两连局第二会话全新对象(与第一局不复用)〔装配生命批 V3〕") + await _stage_faces(main, "V3h 两连局·开局停点", "alive", "alive", "null", {"mf_start": false, "level_vis": true}) + check(main.run_scope.adventure.abandon(), "V3h 放弃受理(第二局)〔装配生命批 V3〕") + for i in range(12): + if main._end_overlay.is_open(): + break + await process_frame + main._flush_row_queue() + for i in range(8): + if main._end_overlay.is_open(): + break + await process_frame + await _stage_faces(main, "V3h 两连局·终局面", "alive", "alive", "null", {"mf_start": false, "end_open": true}) + main._on_battle_end_continue() + await process_frame + await _stage_faces(main, "V3h 两连局·收束", "null", "null", "null", {"mf_start": true, "start_vis": true}) + # 阶段⑨续玩断点(fresh 隔离档+最小合法断点夹具——ui_struct resume 夹具同构;换会话无真空窗) + for p in ["user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + sm.attach_path("user://smoke_lifecycle_resume.json") + sm.set_breakpoint({ + "master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0, + "level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [], + "keystone_drops": [], "bonus_taken": {}, + "bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}}, + }) + var idle_scope = main.run_scope + main._resume_from_menu() + check(main.run_scope != idle_scope and main.run_scope.is_inside_tree(), + "V3i 续玩换会话无真空窗(新冒险会话即刻在树)〔装配生命批 V3〕") + for i in range(10): + await process_frame + await _stage_faces(main, "V3i 续玩断点", "alive", "both", "both", {"mf_start": false}) + main.queue_free() + await process_frame + for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp", + "user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + +# 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采) \ No newline at end of file diff --git a/game/tests/system_test.gd.uid b/game/tests/system_test.gd.uid new file mode 100644 index 00000000..bbc94c2c --- /dev/null +++ b/game/tests/system_test.gd.uid @@ -0,0 +1 @@ +uid://dtoupwbm65jtw diff --git a/game/tests/unit/battle_settlement/roll_multiplier_test.gd b/game/tests/unit/battle_settlement/roll_multiplier_test.gd new file mode 100644 index 00000000..b89ed3ea --- /dev/null +++ b/game/tests/unit/battle_settlement/roll_multiplier_test.gd @@ -0,0 +1,460 @@ +# tests/unit/battle_settlement/roll_multiplier_test.gd — 掷点与乘区单元测试宿主 +# 断言面 = 五套(批 B 断言迁移自 smoke_all.gd,G4 施工组;五套按源编排序分五笔原子提交迁入): +# 敌方攻击类型轴(表列全 normal / spawn 快照注入 / 单据载荷第 9 参 / 克制矩阵命中证词 ±1 位移) +# 护甲等级掷点·行为面(查档纯函数分布 / EV 锚 / nat20 暴击 / 削弱续时 / AC11 逐拍判定 / 伤害下限 / 棋盘来源必中) +# 主角乘区接线 + 硬化折半方向(×1.5 / ×0.75 / ×0.5——池乘区通道) +# 攻击类型 × 护甲克制 + 标错惩罚(护甲快照 / 克制矩阵表值 / 标错负向与标中正向兑付 / 同种子回放) +# 战斗结算 fresh 池攻击链回归(Bug#2——池空时按掷点档位足额兑现) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(各套自建 RngService——源套掷点隔离形态,宿主不持统一流); +# 表加载失败不中止(沿被迁断言面的源语义——诊断在此展开,表值变异的红由具体断言落,承 G2/G3 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/roll_multiplier_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[roll_multiplier_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排序 + _smoke_enemy_attack_type() + _smoke_armor_roll() + _smoke_pool_wiring() + _smoke_damage_types() + _smoke_attack_chain_fresh_pool() + quit(Kit.finish("roll_multiplier_test")) + + +# ---- 敌方攻击类型轴(表列/单位快照/单据载荷/矩阵命中证词)---- +# 克制矩阵命中证词 = 同种子三甲型配对实测(不是查表存在性证词):同种子下 armor_roll 流消费逐位一致、 +# 阈值(hero armor_class)不变,三甲型唯一差异 = 克制点数 cdelta(normal_vs_light 0 / heavy −1 / ward +1) +# → heavy 伤害 == light −1、ward == light +1(要求基线伤害 ≥ 2——地板 1 会吞 −1 位移)。 +# 承伤位移红线(卡片 §8 L3):全局位移只允许 ±1 量级——非此量级 = 矩阵消费面有未盘点的第二落点(停机)。 +func _smoke_enemy_attack_type() -> void: + print("--- 敌方攻击类型轴(迭代 24 N3)---") + # ① 表列:20 行全填 normal(N3 全填不做差异化——Q9) + var at_ok := true + var at_detail := "" + for row in _db.enemy.rows: + var at := String(row.get("attack_type", "")) + if at != "normal": + at_ok = false + at_detail += " %s:%s" % [String(row.get("enemy_id", "?")), at] + Kit.check("敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" % [ + _db.enemy.rows.size(), at_detail if at_detail != "" else "无"], at_ok) + # ②③④ 矩阵命中证词扫描:(3,0) 灰矮人 atk 区间 [2,4] 基线足够((1,2) 熊地精 atk 1 会塌地板); + # 同 seed 三甲型各开一场、各取首个敌方伤害行,对拍 ±1。 + var matrix_found := false + var snap_ok := true + var payload_at := "" + var t_light := -1 + var t_heavy := -1 + var t_ward := -1 + for seed_offset in range(40): + var totals := {} + for armor in ["light", "heavy", "ward"]: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20261013 + seed_offset) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 3, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "armor_type": armor}): + totals[armor] = -1 + continue + if armor == "light": + for u in settlement._enemy_system.all_enemies(): + if String(u.get("attack_type", "")) == "": + snap_ok = false + settlement.minesweeper.settle_batch([{"action": "stop"}]) + for row in rows: + if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action": + totals[armor] = int(row.get("value", 0)) + if armor == "light": + payload_at = String(row.get("attack_type", "")) # 载荷摊平在行顶层(_emit_row 摊平 extra) + break + if not totals.has(armor): + totals[armor] = -1 + if int(totals.get("light", -1)) >= 2 and int(totals.get("heavy", -1)) >= 1 and int(totals.get("ward", -1)) >= 1: + t_light = int(totals["light"]) + t_heavy = int(totals["heavy"]) + t_ward = int(totals["ward"]) + matrix_found = (t_heavy == t_light - 1 and t_ward == t_light + 1) + break + Kit.check("敌方单位快照 attack_type 非空(spawn 注入面——迭代 24 N3)", snap_ok) + Kit.check("克制矩阵命中证词:同种子三甲型实测 heavy %d == light %d − 1 · ward %d == light %d + 1(迭代 24 N3 全局平移 −1/0/+1)" % [ + t_heavy, t_light, t_ward, t_light], matrix_found and t_light >= 2) + Kit.check("敌方伤害技单据载荷 attack_type 非空(= %s——第 9 参传值面,迭代 24 N3)" % payload_at, payload_at == "normal") + + +# ---- 护甲等级掷点·行为面(折扣档分布 / nat20 暴击 / 伤害下限 / 棋盘来源必中)---- + +func _smoke_armor_roll() -> void: + print("--- 护甲等级掷点(查档纯函数 + 暴击 + 伤害下限 + AC12 分布 + 棋盘必中)---") + # ① 查档纯函数:全掷点域逐位对照(AC10 / AC12)+ EV 锚(AC10 期望减免 21.25% —— registry 参考数) + var expect_ac10 := {100: 11, 75: 3, 50: 4, 25: 2} # 掷 10-20 全额 / 7-9 / 3-6 / 1-2 + var expect_ac12 := {100: 9, 75: 3, 50: 4, 25: 4} # 掷 12-20 全额 / 9-11 / 5-8 / 1-4 + for cfg in [{"ac": 10, "expect": expect_ac10}, {"ac": 12, "expect": expect_ac12}]: + var dist := {100: 0, 75: 0, 50: 0, 25: 0} + var tier_ok := true + for roll in range(1, 21): + var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts) + if not dist.has(d): + tier_ok = false + continue + dist[d] += 1 + Kit.check("查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])], tier_ok and dist == cfg.expect) + var ev_sum := 0 + for roll in range(1, 21): + ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts) + Kit.check("AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum, ev_sum == 1575) + # ② nat20 暴击(行为面):探针按行为面扫描 + # 「主角先手首掷 = 20」的种子,不假设 armor_roll scope0 位 + var crit_seed := -1 + var crit_rows := {} + for s in range(1, 800): + crit_rows = _first_hero_attack_rows(s) + if int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll): + crit_seed = s + break + Kit.check("nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.settlement.crit_roll)], crit_seed > 0) + Kit.check("nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)", + int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false))) + Kit.check("nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)], + int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk)) + # ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置 + # (削弱为续时语义,无叠层通道) + var weaken_probe := _weaken_refresh_probe() + Kit.check("削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)", + int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2) + Kit.check("削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)", + int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude)) + # ④ AC11 折扣档分布(行为面):ac_up 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定 + # (ac_up 层数恒 1——局内可达 AC 上限 10+1=11,探针随行取值) + var tiers := _ac11_tier_samples() + var tier_distinct := {} + var tiers_valid := true + for t in tiers: + tier_distinct[int(t)] = true + if not [100, 75, 50, 25].has(int(t)): + tiers_valid = false + Kit.check("AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" % tiers.size(), tiers_valid and tiers.size() >= 3) + Kit.check("折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" % str(tier_distinct.keys()), tier_distinct.size() >= 2) + # ⑤ 棋盘来源必中 + 吃掷点行留档(全战斗流扫描) + var run = _run_battle(1, 0) + var board_ok := true + var attack_ok := true + var all_positive := true + for row in run.stream: + if String(row.get("type", "")) != "damage": + continue + if int(row.get("value", 0)) <= 0: + all_positive = false + var src := String(row.get("source_class", "")) + if src == "hero_attack" or src == "enemy_action": + if not (row.has("roll") and row.has("ac") and row.has("tier")): + attack_ok = false + elif row.has("roll"): + board_ok = false # 棋盘来源(爆裂/直伤/清障炸药)必中全额——不掷不留档 + Kit.check("全战斗流零 v=0 伤害行(伤害下限全局生效)", all_positive) + Kit.check("hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)", attack_ok) + Kit.check("棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)", board_ok) + # ⑥ 全来源伤害下限(任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤/反噬/地形变异/爆裂 + # 全部收在 damage_floor 闸内) + var df: int = int(_db.settlement.damage_floor) + Kit.check("棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)", DamageMath.counter_component(2, -1, df) == 1) + Kit.check("克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)", DamageMath.counter_component(1, -3, df) == 1) + Kit.check("空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)", DamageMath.counter_component(0, 5, df) == 0) + + +# 定位种子下首场战斗第一行主角攻击伤害行(停手轮——零授予零充能,普通分量 = 攻击基线裸值) +func _first_hero_attack_rows(seed_val: int) -> Dictionary: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}): + return {} + settlement.minesweeper.settle_batch([{"action": "stop"}]) + for row in rows: + if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack": + return row + return {} + + +# 削弱续时探测: +# 授予 → 衰减一轮 → 再授予,读 weaken 实例投影 {layers, remaining, pool}。 +# 直授通道(来源格真实坐标惯例——沿前例);depth 2 板(D2 熊地精)仅作宿主,行为与敌种无关。 +func _weaken_refresh_probe() -> Dictionary: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260916) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}): + return {} + var src: Dictionary = {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index} + settlement._status_effects.apply_effect("weaken", src) + settlement._status_effects.decay_round() # remaining 2 → 1(先兑付后衰减——只借衰减器,不结算) + settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动 + for eff in settlement._status_effects.query_active("enemy"): + if String(eff.effect_id) == "weaken": + # 走伤害控制器公有算术面 clamped_pool + return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))} + return {} + + +# AC11 折扣档采样:每停手轮前直授 ac_up ×1(时效 1 逐轮衰减)——敌拍逐拍判定留档。 +# (ac_up 只续时不叠层、层数恒 1——采样上限取 AC11; +# 技能出招为摇号制——扫描种子取首个 ≥2 档样本集。) +func _ac11_tier_samples() -> Array: + for seed_offset in range(20): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260917 + seed_offset) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}): + continue + var guard := 12 + while settlement.battle_result == "" and guard > 0: + guard -= 1 + settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}) + settlement.minesweeper.settle_batch([{"action": "stop"}]) + var tiers: Array = [] + for row in rows: + if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action": + tiers.append(int(row.get("tier", -1))) + var distinct := {} + for t in tiers: + distinct[int(t)] = true + if tiers.size() >= 3 and distinct.size() >= 2: + return tiers + return [] + + +# ---- 全战斗回放驱动(过渡副本——G6 属主件 `_run_battle`/`_auto_play_round` 的宿主侧一致副本: +# armor_roll 棋盘来源必中扫描需真实流驱动而 G6 宿主未建;smoke_all.gd 原件保留供未迁套消费, +# 收尾批与原件去重。副本去 G6 专属「收束相位回 Idle」断言——该条随 `_run_battle` 本体迁其宿主, +# 本副本保持零断言纯驱动形态)---- +func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: + # 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false} + settlement.step_row.connect(func(row): out.stream.append(row)) + settlement.battle_finished.connect(func(payload): out.result = payload.get("result", "")) + settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) + settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) + if not settlement.begin_battle(enc): + out.stream.append({"type": "verdict", "value": "begin_failed"}) + return out + var max_rounds := 40 + while out.result == "" and out.rounds < max_rounds: + out.rounds += 1 + _auto_play_round(settlement, out) + if out.result == "": + out.result = "timeout" + out.terminated = out.result == "victory" or out.result == "defeat" + return out + + +func _auto_play_round(settlement, out: Dictionary) -> void: + var minesweeper = settlement.minesweeper + var action_economy = settlement.action_economy + var start_round: int = settlement.round_index + # 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交) + var guard := 400 + while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "": + guard -= 1 + var plan := AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if action_economy.phase != "Active" or settlement.round_index != start_round: + break + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "": + minesweeper.settle_batch([{"action": "stop"}]) + + +# ---- 主角乘区接线 + 硬化折半方向 ---- +# 同种子停手轮恒同掷点(效果只改乘区、不动掷点流)→ 落地伤害精确关系: +# 增伤 = 基线 ×1.5、乏力 = ×0.75(hero_offense 池乘攻击合计 = 单分量,无直乘旁路); +# 硬化 = 基线 ×0.5 且 < 基线(enemy_defense 池负值折半)。 +# 基线构成:基线 100——hero 攻击类型=normal(锻造工坊配置默认值, +# hero.tres 无两轴字段)对轻甲中性 0 修正(目标 = L1 地精轻甲;AC 0 恒全额非暴击种子)。 +func _smoke_pool_wiring() -> void: + print("--- 主角乘区接线 + 硬化折半方向 ---") + var base_row := _hero_attack_row_with({}, 100, 20260918) + var up_row := _hero_attack_row_with({"effects": ["attack_up"]}, 100, 20260918) + var sapped_row := _hero_attack_row_with({"effects": ["sapped"]}, 100, 20260918) + var fortify_row := _hero_attack_row_with({"effects": ["fortify"]}, 100, 20260918) + var base_v := int(base_row.get("value", -1)) + Kit.check("单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" % base_v, + int(base_row.get("normal", -1)) == base_v and base_v > 0) + Kit.check("增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" % [base_v, int(up_row.get("value", -1))], + int(up_row.get("value", -1)) == int(floor(base_v * 1.5))) + Kit.check("乏力经乘区削减合计(%d → %d = ×0.75)" % [base_v, int(sapped_row.get("value", -1))], + int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75))) + Kit.check("硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" % [base_v, int(fortify_row.get("value", -1))], + int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v) + + +# 构造停手轮首行主角攻击行:opts = {effects: Array};hero_atk 覆盖 100 拉开量级 +# (floor 关系可精确断言);效果直授沿 _weaken_refresh_probe 前例。 +func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: int) -> Dictionary: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": hero_atk_val}): + return {} + for eff in opts.get("effects", []): + settlement._status_effects.apply_effect(String(eff), {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}) + settlement.minesweeper.settle_batch([{"action": "stop"}]) + for row in rows: + if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack": + return row + return {} + + +# ---- 攻击类型 × 护甲克制 + 标错惩罚冒烟(护甲快照/克制矩阵/充能/反噬三选一)---- + +func _smoke_damage_types() -> void: + print("--- 攻击类型 × 护甲克制 + 标错惩罚 ---") + # ① 护甲类型快照传播(spawn 行值 → 单位字典) + var enemy_system = load("res://core/unit_container.gd").new(_db) + Kit.check("goblin 护甲 = light(轻甲——教学位)", + enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light") + var enemy_system_b = load("res://core/unit_container.gd").new(_db) + Kit.check("bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)", + enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light") + var enemy_system_c = load("res://core/unit_container.gd").new(_db) + Kit.check("ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)", + enemy_system_c.spawn(1, 4, int(_db.hero.max_hp), [], -1, -1, [], "", "boss") and String(enemy_system_c.all_enemies()[0].armor_type) == "light") + # ② 克制矩阵表值在位(三档点数 克制 +1 / 中性 0 / 被克 −1,加法核非 percent) + var am: Dictionary = _db.settlement.armor_effectiveness + Kit.check("普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)", + int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1) + Kit.check("穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)", + int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0) + Kit.check("魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +1(加法核)", + int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1) + # ③ 标错(类型化负向兑付)+ 标中正向兑付(封印命中只兑格面资源正向;Boss 行多轮样本;板真相直读保证 hit/miss 确定性) + var run1 = _run_seal_backlash_round(20260912) + var miss_rows := 0 + var negative_rows := 0 + var hit_rows := 0 + for row in run1.rows: + if row.get("type", "") == "seal_miss": + miss_rows += 1 + if String(row.get("source_class", "")) == "seal_miss" and String(row.get("type", "")) in ["effect", "damage"]: + negative_rows += 1 # 标错的负向兑付行(该安全格资源的负向字典行——effect/直伤) + if String(row.get("source_class", "")) == "seal_hit": + hit_rows += 1 # 标中的正向兑付行(该危险格资源的正向字典行——标中只走格面资源正向) + Kit.check("标错封印产生 seal_miss 归因行(×%d)" % miss_rows, miss_rows >= 1) + Kit.check("标错封印兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" % negative_rows, negative_rows >= 1) + Kit.check("封印命中兑付该危险格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" % hit_rows, hit_rows >= 1) + # ④ 同种子回放(极性路由零随机——同种子逐行相同) + var run2 = _run_seal_backlash_round(20260912) + Kit.check("标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)", + JSON.stringify(run1.rows) == JSON.stringify(run2.rows)) + + +# 构造轮:标 1 面"安全格封印"(必 miss → 该格资源负向兑付)+ 标 1 面"雷封印"(必 hit → 正向兑付 + 充能)后停手收轮。 +# 返回 sealed safe 格与其 cell_type(Ready 域内查询——类型化断言的数据面)。 +func _run_seal_backlash_round(seed_val: int) -> Dictionary: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): + return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""} + var minesweeper = settlement.minesweeper + var safe_t := _find_seal_target(minesweeper, false) + var mine_t := _find_seal_target(minesweeper, true) + var safe_type := "" + if not safe_t.is_empty(): + var ident = settlement.query_reward_identity(safe_t.seal_cell) + safe_type = String(ident.get("cell_type", "")) if ident != null else "" + if safe_t.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": safe_t.hero_cell}]) + minesweeper.settle_batch([{"action": "seal_place", "cell": safe_t.seal_cell}]) + if not mine_t.is_empty(): + if mine_t.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": mine_t.hero_cell}]) + minesweeper.settle_batch([{"action": "seal_place", "cell": mine_t.seal_cell}]) + minesweeper.settle_batch([{"action": "stop"}]) + return {"rows": rows, "safe_cell": safe_t.get("seal_cell", Vector2i(-1, -1)), "safe_type": safe_type} + + +# 板真相直读找封印目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径) +# (过渡副本——G5 四套仍消费,smoke_all.gd 原件保留;宿主侧一致副本承共享 helper 双份过渡形态,收尾批去重) +func _find_seal_target(minesweeper, want_mine: bool) -> Dictionary: + var revealed: Array = minesweeper.revealed_cells() + var revealed_set := {} + for c in revealed: + revealed_set[c] = true + for y in minesweeper.current_board().height: + for x in minesweeper.current_board().width: + var c := Vector2i(x, y) + if minesweeper.is_revealed(c) or minesweeper.has_seal(c): + continue + if minesweeper.current_board().is_mine(c) != want_mine: + continue + for n in minesweeper.current_board().neighbors8(c): + if revealed_set.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()): + return {"seal_cell": c, "hero_cell": n} + return {} + + +# ---- 战斗段 fresh 池攻击链回归(playtest-2026-09-12 报告 Bug#2 沉淀—— +# 「Boss 攻击拍零兑现」= 叠满触底 ×0;本断言钉死: +# 池为空时攻击链必须按掷点档位足额兑现,口径: +# d_tick = max(damage_floor, pool_damage(floor(基线×档位%), 0, 0)),档位读数随行重建可归因)---- + +func _smoke_attack_chain_fresh_pool() -> void: + print("--- 战斗结算·fresh 池攻击链回归(Bug#2·掷点口径)---") + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260913) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var ticks: Array = [] # {baseline, attach, roll, tier, crit, tick}——敌行动伤害行重建 + settlement.step_row.connect(func(row): + if row.type == "damage" and row.source_class == "enemy_action": + ticks.append({"baseline": int(row.baseline), "attach": int(row.get("rider", 0)), "roll": int(row.roll), + "tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)})) + Kit.check("fresh 池回归战斗启动(Boss 行)", + settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + # 停手轮:零挖掘零封印 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计) + for i in range(6): + if settlement.battle_result != "": + break + settlement.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size(), ticks.size() >= 2) + var all_full := true + for t in ticks: + var base_factor: float = float(t.tier) * (float(_db.settlement.crit_multiplier) / 100.0 if t.crit else 1.0) + var rolled_base := int(floor(float(t.baseline) * base_factor / 100.0)) + var expected: int = maxi(int(_db.settlement.damage_floor), rolled_base + int(t.get("attach", 0))) + if int(t.tick) != expected or int(t.tick) <= 0: + all_full = false + Kit.check("fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)", all_full) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd new file mode 100644 index 00000000..52526491 --- /dev/null +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -0,0 +1,1015 @@ +# tests/unit/battle_settlement/round_flow_skill_pool_test.gd — 轮内流与技能池单元测试宿主 +# 断言面 = 十二套(批 B 断言迁移自 smoke_all.gd,G6 施工组·主宿主): +# 完整战斗回放 ×2 关(自动玩家确定性——结算步骤流/事件流逐位、判定行、主角先手必兑) +# 战斗收束相位回 Idle(_run_battle 本体随迁断言——roll_multiplier/seal_polarity 两宿主的零断言副本以其为本源) +# 行动经济·轮内刷新(方案 A:Active 相位当场换图继续挖——上限/双账/V5 快照单源/回放) +# 遭遇随机化(区间骰/同种子回放/首遇带固定/空遭遇不可达) +# 五段全序 + 行型封闭(授予→增益/减益→战斗→衰减→判定;案C 段内序) +# 敌人技能池六族(表行集/同种子回放/EV 对锚/死亡打断作废/加载校验拒绝族/妨害必中附加逐拍复算) +# 效果引擎四桶正向验收(adventure 桶跨战持久/battle 桶随宿主销毁) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(各套自建 RngService——源套掷点隔离形态,宿主不持统一流); +# 表加载失败不中止(沿被迁断言面的源语义——承 G2/G3/G4/G5 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/round_flow_skill_pool_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[round_flow_skill_pool_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排(基点 _run_core_smoke 调用序——本组套的相对序) + _smoke_full_battle(1, 0, "L1 · 地精") + _smoke_full_battle(2, 0, "L2 · 熊地精") + _smoke_active_refresh() + _smoke_encounter_roll() + _smoke_settlement_segments() + _smoke_skill_pool() + _smoke_counter_buckets() + quit(Kit.finish("round_flow_skill_pool_test")) + + +# ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ---- + +func _smoke_full_battle(L: int, D: int, label: String) -> void: + print("--- 完整战斗回放:%s ---" % label) + var run1 = _run_battle(L, D) + var run2 = _run_battle(L, D) + Kit.check("%s 战斗在轮次上限内终局(%d 轮,%s)" % [label, run1.rounds, run1.result], run1.terminated) + if JSON.stringify(run1.stream) != JSON.stringify(run2.stream): + for i in range(maxi(run1.stream.size(), run2.stream.size())): + var a := JSON.stringify(run1.stream[i]) if i < run1.stream.size() else "" + var b := JSON.stringify(run2.stream[i]) if i < run2.stream.size() else "" + if a != b: + print(" 流差异 @%d:\n run1=%s\n run2=%s" % [i, a, b]) + break + Kit.check("%s 回放断言:结算步骤流逐行相同" % label, JSON.stringify(run1.stream) == JSON.stringify(run2.stream)) + Kit.check("%s 回放断言:事件流逐位相同" % label, JSON.stringify(run1.events) == JSON.stringify(run2.events)) + Kit.check("%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()], run1.stream.size() > 0) + var has_verdict := false + var hero_attacks := 0 + var hero_breakdown_ok := true + for row in run1.stream: + if row.get("type", "") == "verdict": + has_verdict = true + if row.get("source_class", "") == "hero_attack": + hero_attacks += 1 + # 单分量伤害构成行:hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因) + # 类型键主语 = 锻造工坊配置默认值(hero.tres 无两轴字段;本夹具零注入) + var atk_t: String = String(_db.forge_default_attack_type()) + if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")): + hero_breakdown_ok = false + Kit.check("%s 步骤流含判定行" % label, has_verdict) + # 案C:授予段不产出伤害——进入战斗段时主角恒存活且场上有敌, + # 先手必兑(地图账在战斗段末整体结账)。 + Kit.check("%s 主角先手攻击行必存在(2026-09-11 拍板① + 案C 地图账殿后)×%d" % [label, hero_attacks], hero_attacks >= 1) + Kit.check("%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" % label, hero_breakdown_ok) + + +func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: + # 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false} + settlement.step_row.connect(func(row): out.stream.append(row)) + settlement.battle_finished.connect(func(payload): out.result = payload.get("result", "")) + settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) + settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) + var minesweeper = settlement.minesweeper + var action_economy = settlement.action_economy + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) + if not settlement.begin_battle(enc): + out.stream.append({"type": "verdict", "value": "begin_failed"}) + return out + var max_rounds := 40 + while out.result == "" and out.rounds < max_rounds: + out.rounds += 1 + _auto_play_round(settlement, out) + if out.result == "": + out.result = "timeout" + out.terminated = out.result == "victory" or out.result == "defeat" + if out.terminated: + # P0-3 回归断言:notify_combat_end 曾被并进行尾注释从不执行—— + # 战斗收束后相位必须回 Idle(RAP 清零),任何「相位卡 Waiting」回归在此被抓 + Kit.check("战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" % String(action_economy.phase), action_economy.phase == "Idle") + return out + + +func _auto_play_round(settlement, out: Dictionary) -> void: + var minesweeper = settlement.minesweeper + var action_economy = settlement.action_economy + var start_round: int = settlement.round_index + # 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交) + var guard := 400 + while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "": + guard -= 1 + var plan := AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if action_economy.phase != "Active" or settlement.round_index != start_round: + break + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "": + minesweeper.settle_batch([{"action": "stop"}]) + + + +# ---- 行动经济·轮内刷新冒烟(方案 A:Active 相位当场换图继续挖)---- + + +# ---- 行动经济·轮内刷新冒烟(方案 A:Active 相位当场换图继续挖)---- + +func _smoke_active_refresh() -> void: + print("--- 行动经济·轮内刷新(方案 A)---") + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260912) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + # 计数器用容器(GDScript lambda 对局部 int 按值捕获——int 计数在 lambda 内加无效) + var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0, "waiting_refresh_rejected": true, "waiting_probe_hit": false} + settlement.board_changed.connect(func(): ct["board_swaps"] += 1) + settlement.minesweeper.cell_revealed.connect(func(evt): + if evt.kind == "safe" and evt.channel != "initial": + ct["safe_events"] += 1) + settlement.step_row.connect(func(row): + if row.source_class == "safe_cell": + ct["safe_rows"] += 1 # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行) + # 结算中(Waiting 相位——段内)发起刷新必须被拒(等待期换图通道回归探针: + # 衰减行恒在每结算出现,借其在段内同步探测一次) + if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]): + ct["waiting_probe_hit"] = true + ct["waiting_refresh_rejected"] = not settlement.player_request_refresh()) + Kit.check("轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)", settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + var minesweeper = settlement.minesweeper + # 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——AP 从表读不写死) + for i in range(3): + minesweeper.settle_batch([{"action": "stop"}]) + var rap_banked: int = 3 * int(_db.action_economy.ap_per_round) + Kit.check("三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap], settlement.round_index == 4 and settlement.action_economy.rap == rap_banked) + Kit.check("第 4 轮 Active 相位", settlement.action_economy.phase == "Active") + # 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限) + var plan = AutoPlayer.choose_dig(minesweeper) + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + var ap_before: int = settlement.action_economy.ap + var board_before = minesweeper.current_board() + var swaps_before: int = ct["board_swaps"] + var ok1: bool = settlement.player_request_refresh() + Kit.check("轮内第 1 次刷新受理", ok1) + Kit.check("相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap, settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before) + Kit.check("RAP -刷新价(%d)" % settlement.action_economy.rap, settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost)) + Kit.check("当场换新图(board_changed +1)", int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before) + var plan2 = AutoPlayer.choose_dig(minesweeper) + if plan2.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}]) + minesweeper.settle_batch([{"action": "dig", "cell": plan2.dig_cell}]) + var ok2: bool = settlement.player_request_refresh() + Kit.check("轮内第 2 次刷新受理(RAP=%d)" % settlement.action_economy.rap, ok2) + var plan3 = AutoPlayer.choose_dig(minesweeper) + if plan3.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan3.hero_cell}]) + minesweeper.settle_batch([{"action": "dig", "cell": plan3.dig_cell}]) + var ok3: bool = settlement.player_request_refresh() + Kit.check("第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" % settlement.action_economy.rap, not ok3) + # V5(刷新双账收口):受理计数单源=结算快照 economy.refresh_used,与 can_refresh 门一致 + Kit.check("V5 快照刷新计数=2(受理单源——main 副本已删)〔迭代27 T3〕", int(settlement.presentation_snapshot().get("refresh_used", -1)) == 2) + Kit.check("V5 上限达门禁一致(can_refresh 与快照计数同源)〔迭代27 T3〕", not settlement.can_refresh()) + # 跨图 facts 完整兑现:拒绝后挖到 AP 耗尽自动结算(扩大跨图样本——三段挖掘分属三张图) + var dig_guard := 20 + while settlement.action_economy.phase == "Active" and settlement.round_index == 4 and dig_guard > 0: + dig_guard -= 1 + var p = AutoPlayer.choose_dig(minesweeper) + if p.is_empty(): + minesweeper.settle_batch([{"action": "stop"}]) + break + if p.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": p.hero_cell}]) + minesweeper.settle_batch([{"action": "dig", "cell": p.dig_cell}]) + Kit.check("轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" % [settlement.round_index, settlement.battle_result], settlement.round_index == 5 and settlement.battle_result == "") + Kit.check("跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" % [int(ct["safe_rows"]), int(ct["safe_events"])], int(ct["safe_rows"]) == int(ct["safe_events"])) + Kit.check("跨图挖掘 safe 事件 ≥3(%d——断言非空转)" % int(ct["safe_events"]), int(ct["safe_events"]) >= 3) + Kit.check("结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)", bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"])) + # 回放确定性:同种子两遍步骤流逐行相同(含两次轮内刷新取号序) + var rep1 = _active_refresh_replay() + var rep2 = _active_refresh_replay() + Kit.check("轮内刷新回放断言:同种子两遍步骤流逐行相同", JSON.stringify(rep1) == JSON.stringify(rep2)) + Kit.check("轮内刷新步骤流非空(%d 行)" % rep1.size(), rep1.size() > 0) + + +func _active_refresh_replay() -> Array: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260912) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): + return rows + var minesweeper = settlement.minesweeper + for i in range(3): + minesweeper.settle_batch([{"action": "stop"}]) + for attempt in range(3): + var plan = AutoPlayer.choose_dig(minesweeper) + if not plan.is_empty(): + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + settlement.player_request_refresh() + minesweeper.settle_batch([{"action": "stop"}]) + return rows + + + + +# 板真相直读找封印目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径) + + +# ---- 遭遇随机化冒烟(难度曲线与Boss Core Rule 8:区间骰/encounter 流/可回放/空表定值路径)---- + +func _smoke_encounter_roll() -> void: + print("--- 遭遇随机化(unit_count 区间骰)---") + var counts: Array = [] + for i in range(20): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(30000 + i) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + Kit.check("遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) # 源 quiet=true 计数档——Kit 通道无此参(只影响 PASS 行打印,计数/红绿同口径) + counts.append(settlement.presentation_snapshot().enemies.size()) + # 区间生效:20 个种子应出现 1 和 2(D0 [1,2]) + Kit.check("D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts), counts.has(1) and counts.has(2)) + # 域内断言带标签(断言必须能抓住失效,禁恒真 check(true)) + var all_in_domain := true + for c in counts: + if c < 1 or c > 2: + all_in_domain = false + Kit.check("全部计数在域内 [1,2](分布 %s)" % str(counts), all_in_domain) + # 同种子可回放:两遍同数量(encounter 流确定性) + var r1 := _encounter_replay(30000) + var r2 := _encounter_replay(30000) + Kit.check("同种子遭遇骰可回放(%s == %s)" % [str(r1), str(r2)], r1 == r2) + # D1 [2,3]:seed 30005 路径(任意种子域内即可) + var rng3 = load("res://core/rng_service.gd").new() + rng3.set_master_seed(31000) + var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3) + Kit.check("D1 战斗启动", settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)})) + var n1: int = settlement_d1.presentation_snapshot().enemies.size() + Kit.check("D1 区间 [2,3] 域内(%d 只)" % n1, n1 >= 2 and n1 <= 3) + # 熊地精首遇带固定(教学位不加骰)——首遇带数据驱动定位(契约 8,禁写死坐标) + var bugbear_lead_band := _first_encounter_band(1, "bugbear") + var bugbear_lead_row: Dictionary = {} + for row in _db.enemy.rows: + if int(row.level_index) == 1 and String(row.row_kind) == "battle" \ + and int(row.depth_band) == bugbear_lead_band and String(row.enemy_id) == "bugbear": + bugbear_lead_row = row + break + var rng4 = load("res://core/rng_service.gd").new() + rng4.set_master_seed(31000) + var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4) + settlement_d2.begin_battle({"level_index": 1, "node_depth": bugbear_lead_band, "hero_hp": int(_db.hero.max_hp)}) + Kit.check("熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" % bugbear_lead_band, + settlement_d2.presentation_snapshot().enemies.size() == 1 \ + and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max")) + # 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0: + # 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式) + var depths: Array = [] + for node in _level_battle_nodes(1): + depths.append(int(node.get("node_depth", -1))) + var scan_bad := 0 + var scan_total := 0 + for i in range(12): + var rngs = load("res://core/rng_service.gd").new() + rngs.set_master_seed(32000 + i) + for depth_v in depths: + scan_total += 1 + var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs) + if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}): + scan_bad += 1 + continue + var n: int = settlement_scan.presentation_snapshot().enemies.size() + var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v), "battle")[0] # 行集首行(扫描面 = 战斗节点) + var lo2: int = int(erow2.get("unit_count", 1)) + var hi2: int = int(erow2.get("unit_count_max", lo2)) + if n < 1 or n < lo2 or n > hi2: + scan_bad += 1 + Kit.check("全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" % scan_total, scan_bad == 0) + + +# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维) + + +# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维) +func _row_kind_of(level_index: int, depth: int) -> String: + for n in _level_active_nodes(level_index): + if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss": + return "boss" + return "battle" + + +# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) + + +# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) +func _boss_depth(level_index: int) -> int: + for n in _level_active_nodes(level_index): + if String(n.get("type", "")) == "boss": + return int(n.get("node_depth", -1)) + return -1 + + +# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 + + +# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 +func _first_encounter_band(level_index: int, enemy_id: String) -> int: + var best := -1 + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) != level_index: + continue + if String(row.get("row_kind", "")) != "battle" or String(row.get("enemy_id", "")) != enemy_id: + continue + var band := int(row.get("depth_band", -1)) + if best < 0 or band < best: + best = band + return best + + +# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) + + +# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) +func _level_active_nodes(level_index: int) -> Array: + for row in _db.level.rows: + if int(row.level_index) == level_index: + var tpls: Array = row.get("templates", []) + if not tpls.is_empty(): + return tpls[0].get("nodes", []) + return row.get("nodes", []) + return [] + + +func _level_battle_nodes(level_index: int) -> Array: + var out: Array = [] + for row in _db.level.rows: + if int(row.level_index) == level_index: + for node in _level_active_nodes(level_index): + var t := String(node.get("type", "")) + if t == "battle" or t == "boss": + out.append(node) + return out + + +func _encounter_replay(seed_val: int) -> Array: + var out: Array = [] + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + out.append(settlement.presentation_snapshot().enemies.size()) + return out + + +# ==================== 清图奖励双份兑付链 / 轮内刷新 provenance 守卫 / 窗口锚行 ==================== +# 清图 = 全安全格开完。清图测试用"真雷封印(零误标→完美封印)+ AutoPlayer 逐轮清图"真实流驱动; +# DifficultyTable 注入 hp_mult=10 把 Boss 血量抬到 1500,保证清图批次发生在战斗结束之前(测试台账,跑完即还原)。 + + +# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。 +func _smoke_settlement_segments() -> void: + print("--- 五段全序 + 行型封闭 ---") + Kit.check("segment_order 五段冻结在位(表值 = 拍板序恒等)", + Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER + and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"]) + var run = _run_battle(1, 0) + var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0} + for row in run.stream: + if legacy.has(String(row.get("type", ""))): + legacy[String(row.get("type", ""))] += 1 + var legacy_total := 0 + for k in legacy: + legacy_total += int(legacy[k]) + Kit.check("行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)", legacy_total == 0) + var order_ok := true + var decay_verdict_ok := true + var by_round := {} + for row in run.stream: + var ri: int = int(row.get("round_index", 0)) + if not by_round.has(ri): + by_round[ri] = [] + by_round[ri].append(row) + for ri in by_round.keys(): + var phases: Array = [] + for row in by_round[ri]: + phases.append(_row_phase(row, ri)) + var max_phase := 0 + for p in phases: + if int(p) < max_phase: + order_ok = false + max_phase = maxi(max_phase, int(p)) + var decays := 0 + var verdicts := 0 + for row in by_round[ri]: + if String(row.get("type", "")) == "decay": + decays += 1 + if String(row.get("type", "")) == "verdict": + verdicts += 1 + if decays != 1 or verdicts != 1: + decay_verdict_ok = false # 衰减/判定每结算恰一次 + Kit.check("每轮行相位单调(授予→增益/减益→战斗→衰减→判定)", order_ok) + # 案C 段内序:段内伤害序 = 主角先手 →〔连珠矢随先手段〕→ 敌方逐个 → 地图账殿后。 + # 地图账 = 全部棋盘来源伤害行 + 主角倒下不兑留痕行;违序(先于先手或插进敌方还手中间)即失败。 + var mapdmg_ok := true + for ri_key in by_round.keys(): + var rows_r: Array = by_round[ri_key] + var first_hero := -1 + var last_enemy := -1 + for i in rows_r.size(): + var rw: Dictionary = rows_r[i] + if String(rw.get("type", "")) != "damage": + continue + if String(rw.get("source_class", "")) == "hero_attack" and first_hero < 0: + first_hero = i + elif String(rw.get("source_class", "")) == "enemy_action": + last_enemy = i + for i in rows_r.size(): + var rw: Dictionary = rows_r[i] + var sc := String(rw.get("source_class", "")) + var is_board_acct: bool = (String(rw.get("type", "")) == "damage" \ + and not ["hero_attack", "enemy_action", "fx_streak_segments"].has(sc)) \ + or String(rw.get("forfeit", "")) == "hero_down" + if not is_board_acct: + continue + if (first_hero >= 0 and i < first_hero) or (last_enemy >= 0 and i < last_enemy): + mapdmg_ok = false + Kit.check("案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账;连珠矢随先手段)", mapdmg_ok) + Kit.check("衰减/判定段每结算恰一段(先兑付后衰减——含末轮)", decay_verdict_ok) + + +# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定 + + +# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定 +func _row_phase(row: Dictionary, ri: int) -> int: + match String(row.get("type", "")): + "decay": + return 3 + "verdict": + return 4 + "cast": + return 2 + "damage": + return 2 # 案C:全部伤害兑付=战斗段(主角先手/敌方还手/地图账殿后统一结账) + "reward_ineffective": + return 2 if String(row.get("forfeit", "")) == "hero_down" else 0 # 战斗段末地图账不兑留痕 vs 授予段未生效 + match String(row.get("source_class", "")): + "safe_cell", "mine_hit", "seal_hit", "seal_miss", "clear_reward": + if int(row.get("round_index", ri)) < ri: + return 1 # 前轮授予实例的续存组合行(增益/减益段) + return 0 + _: + return 0 + + +# 清图驱动(真实流):标一面真雷封印(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。 +# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。 + + +# ==================== 敌人技能池(开轮摇号出招系统) ==================== +# 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 / +# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。 + +func _smoke_skill_pool() -> void: + print("--- 敌人技能池(开轮摇号 · 冷却/保底两闸 · 三乘法链 · 独立子流)---") + _smoke_skill_pool_data() + _smoke_skill_pool_replay_independence() + _smoke_skill_pool_ev_anchor() + _smoke_skill_pool_death_void() + _smoke_skill_pool_validation_reject() + _smoke_debuff_rider() + + +# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言) + + +# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言) +func _smoke_skill_pool_data() -> void: + Kit.check("技能表十四行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕)", _db.skill.rows.size() == 14) + var basic: Dictionary = _db.skill.row_by_id("attack") + Kit.check("普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)", + not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0 + and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0))) + var lunge: Dictionary = _db.skill.row_by_id("lunge") + Kit.check("突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)", not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2) + var charge_row: Dictionary = _db.skill.row_by_id("charge") + Kit.check("蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)", not charge_row.is_empty() and String(charge_row.buff_ref) == "empower") + var bite: Dictionary = _db.skill.row_by_id("bite") + Kit.check("撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)", not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1) + var venom: Dictionary = _db.skill.row_by_id("venom_burst") + Kit.check("毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)", + not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4 + and is_equal_approx(float(venom.rider_mult), 0.75)) + var venom_sole_ok := true + for row in _db.enemy.rows: + for entry in row.pool: + if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider": + venom_sole_ok = false + Kit.check("毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)", venom_sole_ok) + # 妨害技首发行 + off_guard 落点 + 实装面收窄 + var snare: Dictionary = _db.skill.row_by_id("web_snare") + Kit.check("蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)", + not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard" + and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4) + var snare_sole_ok := true + for row in _db.enemy.rows: + for entry in row.pool: + if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider": + snare_sole_ok = false + Kit.check("蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)", snare_sole_ok) + var off_guard_row: Dictionary = _db.status_effect_row("off_guard") + Kit.check("失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)", not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac") + # 普通敌错峰两新行——毒吐/盾击(V3) + var spit: Dictionary = _db.skill.row_by_id("venom_spit") + Kit.check("毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)", + not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \ + and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult")) + var bash: Dictionary = _db.skill.row_by_id("shield_bash") + Kit.check("盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)", + not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \ + and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult")) + var spider_lead_band := _first_encounter_band(2, "giant_spider") # 首遇带数据驱动定位(契约 8) + var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇带不投(Q6——教学位池零新招) + var bash_sole_ok := true # 盾击只挂灰矮人族三行 + for row in _db.enemy.rows: + for entry in row.pool: + var sid := String(entry.skill_id) + if sid == "venom_spit" and not (String(row.enemy_id) == "giant_spider" and int(row.level_index) == 2 \ + and int(row.depth_band) > spider_lead_band): + spit_sole_ok = false + if sid == "shield_bash" and not (String(row.enemy_id) == "duergar" and int(row.level_index) == 3): + bash_sole_ok = false + Kit.check("毒吐投放面(仅 depth_band > 巨蜘蛛首遇带(%d)的巨蜘蛛族行——首遇带池不动,迭代 24 N2/Q6)〔fv24〕" % spider_lead_band, spit_sole_ok) + Kit.check("盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)", bash_sole_ok) + # 首遇带行池恒 {attack:6, bite:2, charge:1}(去写死深度判据——带键口径) + var spider_lead_pool := {} + for row in _db.enemy.rows: + if int(row.level_index) == 2 and String(row.row_kind) == "battle" \ + and int(row.depth_band) == spider_lead_band and String(row.enemy_id) == "giant_spider": + for entry in row.pool: + spider_lead_pool[String(entry.skill_id)] = int(entry.weight) + Kit.check("巨蜘蛛首遇带行 pool 恒 {attack:6, bite:2, charge:1}(教学位池零新招——毒吐首投在更深的带)〔fv24〕", spider_lead_pool == {"attack": 6, "bite": 2, "charge": 1}) + # 行集键 = (level_index, row_kind, depth_band, enemy_id)——分支节点图批起 bugbear 等敌种跨关异池, + # enemy_id 不再唯一;同带混编多行时每行各一池(行序 = 文件序 = 契约序) + var expect_pools := { + "1_battle_0_goblin": {"attack": 9, "lunge": 1}, + "1_battle_1_goblin": {"attack": 9, "lunge": 1}, + "1_battle_2_bugbear": {"attack": 6, "charge": 2, "heavy": 2}, + "1_battle_3_hobgoblin": {"attack": 6, "charge": 2, "heavy": 2}, + "1_boss_4_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, + "2_battle_0_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, + "2_battle_1_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, + "2_battle_4_giant_spider": {"attack": 6, "bite": 2, "charge": 1}, + "2_battle_5_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_battle_5_bugbear": {"attack": 5, "lunge": 2, "heavy": 2}, + "2_battle_6_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_battle_7_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_boss_9_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, + "3_battle_0_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, + "3_battle_1_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_battle_4_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_battle_5_duergar": {"attack": 5, "lunge": 2, "heavy": 2, "shield_bash": 2}, + "3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, + "3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + } + var pools_ok := true + for row in _db.enemy.rows: + var rk := "%d_%s_%d_%s" % [int(row.level_index), String(row.row_kind), int(row.depth_band), String(row.enemy_id)] + if not expect_pools.has(rk): + pools_ok = false + continue + var got := {} + for entry in row.pool: + got[String(entry.skill_id)] = int(entry.weight) + if got != expect_pools[rk]: + pools_ok = false + Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok) + var basic_count_ok := true + for row in _db.enemy.rows: + var n := 0 + for entry in row.pool: + var srow: Dictionary = _db.skill_row(String(entry.skill_id)) + if not srow.is_empty() and String(srow.type) == "basic": + n += 1 + if n != 1: + basic_count_ok = false + Kit.check("每池恰一行普攻(空池不可能的加载校验同款)", basic_count_ok) + # 池构成纪律复查(V4):零违反——增益 ≤1 / 妨害 ≤1(敌人系统 v0.13 结构冻结;载入期 + # validate_skill_pool_links 已硬闸,此处冒烟侧显式复核新投放后仍绿) + var comp_ok := true + for row in _db.enemy.rows: + var n_buff := 0 + var n_debuff := 0 + for entry in row.pool: + var srow: Dictionary = _db.skill_row(String(entry.skill_id)) + if srow.is_empty(): + continue + if String(srow.type) == "buff": + n_buff += 1 + elif String(srow.type) == "debuff": + n_debuff += 1 + if n_buff > 1 or n_debuff > 1: + comp_ok = false + Kit.check("逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)", comp_ok) + # Boss 半血解锁技·编排集成断言(真实编排回路:半血 → 下轮开轮闩检查 → 解锁行恰一次) + var st_unlock: Dictionary = {"rows": []} + var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242)) + sett_u.step_row.connect(func(row): st_unlock["rows"].append(row)) + Kit.check("前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)", sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面) + sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65)) + Kit.check("前置:Boss 压到阈值 65(=round(130×0.5))", int(boss_u.hp) == 65) + sett_u.minesweeper.settle_batch([{"action": "stop"}]) # 收轮 → 结算 → 次轮开轮闩检查 + var unlock_rows: Array = [] + for r in st_unlock["rows"]: + if String(r.get("highlight", "")) == "skill_unlock": + unlock_rows.append(r) + Kit.check("解锁归因行恰一次(×%d——闩去重,二次检查零行)" % unlock_rows.size(), unlock_rows.size() == 1) + if unlock_rows.size() == 1: + Kit.check("解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)", String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒") + Kit.check("解锁行型 = highlight 既有行型(零行型新增——行协议封闭)", String(unlock_rows[0].get("type", "")) == "highlight") + var rollable_ok := true + for depth in [0, 1, 2, 3, 4]: + var es = load("res://core/unit_container.gd").new(_db) + es.spawn(1, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(1, int(depth))) + for u in es.all_enemies(): + if es.rollable_skills(u).is_empty(): + rollable_ok = false + for depth in [0, 1, 4, 5, 6, 7, 8]: + var es2 = load("res://core/unit_container.gd").new(_db) + es2.spawn(2, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(2, int(depth))) + for u in es2.all_enemies(): + if es2.rollable_skills(u).is_empty(): + rollable_ok = false + Kit.check("全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)", rollable_ok) + # 状态表新行 + 敌方防御池映射 + var empower: Dictionary = _db.status_effect_row("empower") + var fortify: Dictionary = _db.status_effect_row("fortify") + Kit.check("蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)", not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100) + Kit.check("硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)", not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50) + Kit.check("effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)", + String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \ + and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense") + + +# ② 同种子回放逐位复现(D3 大地精,多实例同种) +# 单敌轨迹不独立复放——轨迹取数与其它流交错,无法脱离全序重建; +# 同种子全战斗逐位复现由 ② 全对拍承载,单点定位归事件日志。 + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# ==================== GP+商店房冒烟 ==================== +# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 + +# ---- ① GP 冒险钱包·到账 ---- + + +# ② 同种子回放逐位复现(D3 大地精,多实例同种) +# 单敌轨迹不独立复放——轨迹取数与其它流交错,无法脱离全序重建; +# 同种子全战斗逐位复现由 ② 全对拍承载,单点定位归事件日志。 +func _smoke_skill_pool_replay_independence() -> void: + var seed_val := -1 + for s in range(1, 60): + var seqs := _record_pool_trajectories(s, 1) + if not seqs.is_empty() and int(seqs.count) >= 2: + seed_val = s + break + Kit.check("多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val, seed_val > 0) + # 敌 hp 低(表值 6)时 hero_atk=1 十轮内必然击杀(实例错位、轨迹断裂)—— + # 观测窗取 5 轮(5 拍 ×1 伤 < 6 HP 全员存活;仍覆盖保底 M=4 的触发窗)。 + var rounds := 5 + var run1 := _record_pool_trajectories(seed_val, rounds) + var run2 := _record_pool_trajectories(seed_val, rounds) + Kit.check("同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" % [rounds, int(run1.count)], not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs)) + + +# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数) + + +# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数) +func _record_pool_trajectories(seed_val: int, rounds: int) -> Dictionary: + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": 300, "hero_max_hp": 300, "hero_atk": 1}): + return {} + var seqs: Array = [] + for u in settlement._enemy_system.all_enemies(): + seqs.append([]) + for r in rounds: + var handles: Array = settlement._enemy_system.all_enemies() + for i in handles.size(): + seqs[i].append(settlement._enemy_system.pending_action(handles[i])) + settlement.minesweeper.settle_batch([{"action": "stop"}]) + if settlement.battle_result != "": + return {} + return {"count": seqs.size(), "seqs": seqs} + + +# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。 +# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带; +# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。 + + +# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。 +# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带; +# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。 +func _smoke_skill_pool_ev_anchor() -> void: + var bands := {"goblin": [1.00, 1.20], "bugbear": [0.97, 1.03], "ogre_chief": [1.10, 1.30]} # 酋长重锚(震地怒击半血解锁入池,含闸长期速率上移:实测 1.1948;EV 锚注 enemy.tres) + var species := ["goblin", "bugbear", "ogre_chief"] + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260921) + var n_beats := 5000 + var pity_m: int = int(_db.skill.pity_threshold) + for si in species.size(): + var eid: String = species[si] + var template: Array = [] + for row in _db.enemy.rows: + if String(row.enemy_id) == eid: + template = row.pool + break + var pool: Array = [] + for entry in template: + pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0}) + var streak := 0 + var ev_sum := 0.0 + for k in n_beats: + var entries: Array = [] + for e in pool: + if int(e.cooldown_left) <= 0: + entries.append({"skill_id": e.skill_id, "weight": e.weight}) + if streak >= pity_m: + var specials: Array = [] + for e in pool: + var sr: Dictionary = _db.skill_row(String(e.skill_id)) + if not sr.is_empty() and String(sr.type) != "basic": + specials.append({"skill_id": e.skill_id, "weight": e.weight}) + if not specials.is_empty(): + entries = specials + var sid := SkillLottery.draw(entries, rng.open_stream("behavior_roll")) + var srow: Dictionary = _db.skill_row(sid) + var mult := 0.0 + if String(srow.type) == "basic": + mult = 1.0 + elif String(srow.type) == "damage": + mult = float(srow.get("damage_mult", 1.0)) + ev_sum += mult + for e in pool: + e.cooldown_left = maxi(0, int(e.cooldown_left) - 1) + if String(e.skill_id) == sid: + e.cooldown_left = int(srow.cooldown_beats) + streak = streak + 1 if String(srow.type) == "basic" else 0 + var measured: float = ev_sum / float(n_beats) + var band: Array = bands[eid] + var in_band: bool = measured >= float(band[0]) and measured <= float(band[1]) + Kit.check("EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" % [eid, measured, float(band[0]), float(band[1]), n_beats], in_band) + + +# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账) + + +# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账) +func _smoke_skill_pool_death_void() -> void: + var void_seed := -1 + for s in range(1, 400): + var rg = load("res://core/rng_service.gd").new() + rg.set_master_seed(s) + var erow: Dictionary = _db.resolve_enemy_rowset(1, 2, "battle")[0] # 行集首行(战斗节点 battle 键域) + var entries: Array = [] + for entry in erow.pool: + entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)}) + var sid := SkillLottery.draw(entries, rg.open_stream("behavior_roll")) + if String(_db.skill_row(sid).type) != "basic": + void_seed = s + break + Kit.check("死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed, void_seed > 0) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(void_seed) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + Kit.check("死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)", settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999})) + var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读) + var pending: String = settlement._enemy_system.pending_action(u) + Kit.check("开轮已摇定本拍动作(待发 = %s)" % pending, pending != "") + settlement.minesweeper.settle_batch([{"action": "stop"}]) + var enemy_rows := 0 + for row in rows: + if String(row.get("source_class", "")) == "enemy_action": + enemy_rows += 1 + Kit.check("先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)", enemy_rows == 0) + var cd_sum := 0 + for e in u.pool: + cd_sum += int(e.cooldown_left) + Kit.check("作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)", cd_sum == 0 and int(u.basic_streak) == 0) + + +# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律) + + +# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律) +func _smoke_skill_pool_validation_reject() -> void: + var skill_table = load("res://data/schemas/skill_table.gd").new() + var enemy_table = load("res://data/schemas/enemy_table.gd").new() + enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}]) + Kit.check("加载校验拒绝:池条目 skill_id 悬空致命", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill")) + skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}]) + Kit.check("加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) + skill_table.rows.assign([ + {"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3}, + {"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}]) + enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}]) + Kit.check("加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) + skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}]) + Kit.check("加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)", not skill_table.validate().is_empty()) + skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}]) + Kit.check("加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)", not skill_table.validate().is_empty()) + skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 30.0, "cooldown_beats": 1}]) + Kit.check("加载校验拒绝:damage_mult 越域 [1.1,16]", not skill_table.validate().is_empty()) + var et2 = load("res://data/schemas/enemy_table.gd").new() + et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "a", "weight": 0}]}]) + Kit.check("加载校验拒绝:池权重 ≤ 0", not et2.validate().is_empty()) + et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": []}]) + Kit.check("加载校验拒绝:空池(禁)", not et2.validate().is_empty()) + # 妨害 / 必中附加分量校验组 + skill_table.rows.assign([ + {"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}, + {"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}]) + enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}]) + Kit.check("加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) + skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}]) + Kit.check("加载校验拒绝:妨害技缺 buff_ref(效果引用)", not skill_table.validate().is_empty()) + skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}]) + Kit.check("加载校验拒绝:rider_mult 越域 (0,1]", not skill_table.validate().is_empty()) + skill_table.rows.assign([{"skill_id": "b", "display_name": "b", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "rider_mult": 0.5, "cooldown_beats": 1}]) + Kit.check("加载校验拒绝:非伤害技带必中附加分量", not skill_table.validate().is_empty()) + # 施放类 side 守卫:妨害技引用敌方侧状态 → 拒绝(增益技引用主角侧同罪,本组以妨害反例覆盖) + skill_table.rows.assign([ + {"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0}, + {"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}]) + enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}]) + Kit.check("加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side")) + + +func _has_err_category(errs: Array, category: String) -> bool: + for e in errs: + if String(e.get("category", "")) == category: + return true + return false + + +# ⑦ 实装探针(妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔): +# 停手轮驱动 (2,9)——零挖掘零封印 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行 +# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。 + + +# ⑦ 实装探针(妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔): +# 停手轮驱动 (2,9)——零挖掘零封印 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行 +# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。 +func _smoke_debuff_rider() -> void: + var venom_seed := -1 + var venom_check_ok := true + var venom_rows_seen := 0 + var snare_seed := -1 + var base_ac: int = int(_db.hero.armor_class) + var off_m: int = int(_db.status_effect_row("off_guard").magnitude) + var floor_v: int = int(_db.settlement.damage_floor) + for s in range(1, 401): + if venom_seed > 0 and snare_seed > 0: + break + var rg = load("res://core/rng_service.gd").new() + rg.set_master_seed(s) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): + continue + var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放) + for i in range(6): + if settlement.battle_result != "": + break + settlement.minesweeper.settle_batch([{"action": "stop"}]) + var has_venom := false + var has_snare := false + var snare_round := -1 + for row in rows: + if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action": + if String(row.get("type", "")) == "cast" and String(row.get("skill_id", "")) == "web_snare": + has_snare = true + snare_round = int(row.get("round_index", -1)) + continue + if String(row.get("skill_id", "")) == "venom_burst": + has_venom = true + venom_rows_seen += 1 + var rider_v: int = int(row.get("rider", -1)) + if rider_v != maxi(floor_v, int(floor(float(boss_atk) * 0.75))): + venom_check_ok = false + # 终值精确复算(停手轮池恒空):主分量链结果 + 必中附加后置 + var base_factor: float = float(int(row.tier)) * (float(_db.settlement.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0) + var rolled_main: int = int(floor(float(int(row.baseline)) * base_factor / 100.0)) + var expected: int = maxi(floor_v, rolled_main + int(row.get("attach", 0))) + rider_v + if int(row.value) != expected: + venom_check_ok = false + elif row.has("rider"): + venom_check_ok = false # rider 键只允许出现在带 rider_mult 的技能行 + if has_snare: + # 施放后主角掷点阈值下调(off_guard ac 轴):同轮起的伤害行阈值读数 = 基准 AC + 失衡幅度。 + # 只取"可观测"种子(施放轮后仍有敌方伤害行——施放在末轮随战斗终局属合法不可观测,不作失败)。 + var seen_lowered := false + for row in rows: + if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action": + continue + if int(row.get("round_index", -1)) >= snare_round and int(row.get("ac", -999)) == base_ac + off_m: + seen_lowered = true + break + if seen_lowered and snare_seed < 0: + snare_seed = s + if has_venom and venom_seed < 0: + venom_seed = s + Kit.check("毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" % [venom_seed, venom_rows_seen], venom_seed > 0 and venom_rows_seen >= 1) + Kit.check("毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)", venom_check_ok) + Kit.check("蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" % snare_seed, snare_seed > 0) + + +# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域图版行进(关卡节点地图 v1.0 图版)---- + + +# ---- 天赋树六键行为冒烟(V10:开局姿态窗/危险直觉限次/财富之道聚散/守侧失误补偿; +# 引擎直构 + 编排授予段直注(_grant_seg._judge_seal=结算授予段封印判定单口))---- +func _smoke_counter_buckets() -> void: + print("--- 效果引擎无状态化四桶正向验收(卡② §9 V1/V6)---") + # V1——adventure 桶跨战持久:同一 RunLedger 下两战各真实 request_dig 一次, + # 断付费计数经 ctx 就地写入宿主字典并跨战累积(副本搬运态:读不到上一战值→第 2 战仍 1,此断言会红)。 + var led = load("res://core/run_ledger.gd").new() + var RigS = load("res://core/battle_orchestrator.gd") + var RngS = load("res://core/rng_service.gd") + var enc1 = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": ["twin_pick"], "tree_rules": [], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, + "node_type": "battle", "ledger": led} + var o1 = RigS.new(_db, _fresh_rng(779301)) + o1.begin_battle(enc1) + var dig1: bool = o1.action_economy.request_dig(0, false) + Kit.check("V1 第1战真实 request_dig 受理(begin_battle 后 ACTIVE 相位)〔无状态化 V1〕", dig1) + Kit.check("V1 第1战付费挖格经 ctx 就地写 adventure 桶=1(无 export 搬运时点)〔无状态化 V1〕", int(led.adventure_counters.get("adv_twin_digs", 0)) == 1) + var enc2 = enc1.duplicate(true) + enc2["battle_index"] = 2 + var o2 = RigS.new(_db, _fresh_rng(779302)) + o2.begin_battle(enc2) + Kit.check("V1 第2战 begin_battle 经 ctx 读到上一战持久值=1(读路径走宿主字典引用,非副本种子回灌)〔无状态化 V1〕", EffectEngine.counter(o2.counters_ctx, "adv_twin_digs") == 1) + o2.action_economy.request_dig(0, false) + Kit.check("V1 第2战挖后 adventure 桶跨战累积=2(run_ledger:42 判空早退缺陷消解的回归钉面——旧副本态恒 1)〔无状态化 V1〕", int(led.adventure_counters.get("adv_twin_digs", 0)) == 2) + # V6——battle 桶随宿主销毁:第1战标错落 insurance_used(battle 桶无前缀),次战新编排器读 0。 + var encA = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": ["perfect_insurance"], "tree_rules": [], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, + "node_type": "battle", "ledger": load("res://core/run_ledger.gd").new()} + var oA = RigS.new(_db, _fresh_rng(779401)) + oA.begin_battle(encA) + var safe_a: Variant = null + for ca in oA.minesweeper.current_board().all_cells(): + if not oA.minesweeper.current_board().is_mine(ca): + safe_a = ca + break + oA._grant_seg._judge_seal({"cell": safe_a, "hit": false}, 1) + Kit.check("V6 第1战标错落 battle 桶 insurance_used=1(_apply_counter_hit 静态写口经 ctx)〔无状态化 V6〕", EffectEngine.counter(oA.counters_ctx, "insurance_used") == 1) + var encB = encA.duplicate(true) + encB["battle_index"] = 2 + var oB = RigS.new(_db, _fresh_rng(779402)) + oB.begin_battle(encB) + Kit.check("V6 battle 桶随宿主销毁:次战新编排器 fx_counters_battle 天然空,读 insurance_used=0〔无状态化 V6〕", EffectEngine.counter(oB.counters_ctx, "insurance_used") == 0) diff --git a/game/tests/unit/battle_settlement/seal_polarity_test.gd b/game/tests/unit/battle_settlement/seal_polarity_test.gd new file mode 100644 index 00000000..637d0a1b --- /dev/null +++ b/game/tests/unit/battle_settlement/seal_polarity_test.gd @@ -0,0 +1,807 @@ +# tests/unit/battle_settlement/seal_polarity_test.gd — 封印与极性单元测试宿主 +# 断言面 = 八套(批 B 断言迁移自 smoke_all.gd,G5 施工组;八套按源编排序分八笔原子提交迁入): +# 末点 AP 封印轮末结算(playtest 缺陷②回归——action_economy 末点花费与轮终结) +# 封印不可逆四情况(禁撤封印 + 挖封印格兑付改判——minesweeper_rules 拒绝面) +# 清图奖励双份兑付链(真实流完美封印——orchestrator grant_clear 身份兑付) +# 清图奖励弱形式分支(零兑付 / 高光守卫 / 第二份拒——直调编排私有面构造) +# 轮内刷新 provenance 守卫(刷新图清图不发清图奖励) +# 极性路由 + 封印溢价 125%(挖安全格正向 / 挖危险格负向 / 标中标空兑付极性) +# 负向字典逐型直验(效果行 / heal 直伤 / blast 警觉 / action 行动点排队) +# 格面资源资格面(揭示事件 resource 标记 + 封锁格身份查询) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(各套自建 RngService——源套掷点隔离形态,宿主不持统一流); +# 表加载失败不中止(沿被迁断言面的源语义——诊断在此展开,表值变异的红由具体断言落,承 G2/G3/G4 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/seal_polarity_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3/G4 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[seal_polarity_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排序 + _smoke_seal_ap_exhausted() + _smoke_clear_reward_chain() + _smoke_refresh_provenance_guard() + _smoke_polarity_routing() + _smoke_seal_irreversible_quadrants() + _smoke_negative_dictionary() + _smoke_resource_surface() + quit(Kit.finish("seal_polarity_test")) + + +# ---- 末点 AP 封印轮末结算(playtest 缺陷②回归:末点 AP 花在封印必须终结轮次进入下一轮)---- + +func _smoke_seal_ap_exhausted() -> void: + print("--- 排雷玩法·封印动作耗尽末点 AP 回归 ---") + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260911) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rounds: Array = [] + settlement.round_started.connect(func(ri): rounds.append(ri)) + Kit.check("回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)", + settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + var minesweeper = settlement.minesweeper + # 恰花 (AP-1) 点挖掘,留末点给封印(表驱动——AP 从表读不写死) + var ap_total: int = int(_db.action_economy.ap_per_round) + for i in range(ap_total - 1): + if settlement.action_economy.phase != "Active" or settlement.action_economy.ap == 0: + break + var plan = AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + Kit.check("%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap], + settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1) + # 末点 AP 花在封印——轮次必须终结并结算进入第 2 轮 + # 封印目标 = 挖掘策略的下一目标格(构造保证:未开、与小人邻接——先把小人移到其邻接已开格) + var plan2 = AutoPlayer.choose_dig(minesweeper) + Kit.check("仍存在可挖目标(封印目标来源)", not plan2.is_empty()) + var out: Array = [] + if not plan2.is_empty(): + if plan2.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}]) + out = minesweeper.settle_batch([{"action": "seal_place", "cell": plan2.dig_cell}]) + Kit.check("末点 AP 封印被受理", not out.is_empty() and bool(out[0].accepted)) + Kit.check("封印动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" % str(rounds), rounds.size() >= 2) +# ==================== 清图奖励双份兑付链 / 轮内刷新 provenance 守卫 / 窗口锚行 ==================== +# 清图 = 全安全格开完。清图测试用"真雷封印(零误标→完美封印)+ AutoPlayer 逐轮清图"真实流驱动; +# DifficultyTable 注入 hp_mult=10 把 Boss 血量抬到 1500,保证清图批次发生在战斗结束之前(测试台账,跑完即还原)。 + +const CLEAR_SMOKE_DEPTH := 4 +const CLEAR_SMOKE_HERO_HP := 100000 +var _regular_clear_reward_rows := 0 # 常规(非刷新图)清图的 clear_reward 行数——刷新守卫对照基线 + + +# 板真相直读找封印目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径) +func _find_seal_target(minesweeper, want_mine: bool) -> Dictionary: + var revealed: Array = minesweeper.revealed_cells() + var revealed_set := {} + for c in revealed: + revealed_set[c] = true + for y in minesweeper.current_board().height: + for x in minesweeper.current_board().width: + var c := Vector2i(x, y) + if minesweeper.is_revealed(c) or minesweeper.has_seal(c): + continue + if minesweeper.current_board().is_mine(c) != want_mine: + continue + for n in minesweeper.current_board().neighbors8(c): + if revealed_set.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()): + return {"seal_cell": c, "hero_cell": n} + return {} + + +# ---- 封印不可逆四情况冒烟(禁撤封印 + 挖封印格兑付改判)---- +# 四情况:① 插危险格不动 = 标中奖励照常;② 插危险格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发; +# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。 +# 拒绝面:seal_pull 出目录(out_of_catalog);重复封印仍 seal_present 拒绝。 +# 场景 A(④ + 拒绝面):标安全格 → seal_pull 拒绝 → 重复封印拒绝 → 直接挖开 → 收轮。 +# 场景 B(②):标危险格 → 直接挖开 → 收轮。 +# 场景 C(①③):标安全格 + 标危险格 → 不挖收轮 → 标空/标中判定照常。 + + +func _auto_play_round(settlement, out: Dictionary) -> void: + var minesweeper = settlement.minesweeper + var action_economy = settlement.action_economy + var start_round: int = settlement.round_index + # 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交) + var guard := 400 + while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "": + guard -= 1 + var plan := AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if action_economy.phase != "Active" or settlement.round_index != start_round: + break + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "": + minesweeper.settle_batch([{"action": "stop"}]) + + + +# ---- 行动经济·轮内刷新冒烟(方案 A:Active 相位当场换图继续挖)---- + + +func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: + # 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false} + settlement.step_row.connect(func(row): out.stream.append(row)) + settlement.battle_finished.connect(func(payload): out.result = payload.get("result", "")) + settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) + settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) + if not settlement.begin_battle(enc): + out.stream.append({"type": "verdict", "value": "begin_failed"}) + return out + var max_rounds := 40 + while out.result == "" and out.rounds < max_rounds: + out.rounds += 1 + _auto_play_round(settlement, out) + if out.result == "": + out.result = "timeout" + out.terminated = out.result == "victory" or out.result == "defeat" + return out + + +# 清图驱动(真实流):标一面真雷封印(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。 +# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。 +func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictionary: + _db.difficulty.rows.append({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hp_mult": 10.0, "atk_mult": 1.0}) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + settlement.step_row.connect(func(row): rows.append(row)) + var res := {"cleared": false, "refreshed": false, "rounds": 0} + if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "node_type": "boss", + "hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}): + _db.difficulty.rows.clear() + return res + if from_refresh: + for i in range(3): + settlement.minesweeper.settle_batch([{"action": "stop"}]) + res["refreshed"] = settlement.player_request_refresh() + var drive := _drive_clear(settlement) + res["cleared"] = drive.cleared + res["rounds"] = drive.rounds + _db.difficulty.rows.clear() # 测试台账还原(后续冒烟基线) + return res + + +func _drive_clear(settlement) -> Dictionary: + var seal_done := false + var rounds := 0 + while rounds < 60: + rounds += 1 + var ms = settlement.minesweeper + if not seal_done: + var t := _find_seal_target(ms, true) + if not t.is_empty(): + if t.hero_cell != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": t.hero_cell}]) + ms.settle_batch([{"action": "seal_place", "cell": t.seal_cell}]) + seal_done = true # 一面真雷封印 = 完美封印前半(零误标),后半靠 AutoPlayer 清图 + var seq_before: int = int(settlement.diagnostic_snapshot().board_seq) + var out := {"result": "", "rounds": 0, "terminated": false, "stream": []} + _auto_play_round(settlement, out) + if int(settlement.diagnostic_snapshot().board_seq) > seq_before: + return {"cleared": true, "rounds": rounds} # 清图换图 = board_seq 递增 + if settlement.battle_result != "": + return {"cleared": false, "rounds": rounds} + return {"cleared": false, "rounds": rounds} + + +func _rows_of_type(rows: Array, t: String) -> Array: + var out: Array = [] + for r in rows: + if String(r.get("type", "")) == t: + out.append(r) + return out + + +func _has_source_row(rows: Array, t: String, source: String, batch_key: String = "") -> bool: + for r in rows: + if String(r.get("type", "")) != t or String(r.get("source_class", "")) != source: + continue + if batch_key == "" or String(r.get("batch_key", "")) == batch_key: + return true + return false + + +func _has_effect_id(rows: Array, effect_id: String) -> bool: + for r in rows: + if String(r.get("effect_id", "")) == effect_id: + return true + return false + + +# 手搓战斗状态袋单点(7 处字面量收在一处;_autoplay_battle 直接消费的最小字段族。 +# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面) + + +const POSITIVE_EFFECTS := ["attack_up", "ac_up", "weaken", "gp_gain"] +const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "alert", "gp_loss"] + + +# 构造验收谓词(hunt 用——种子按行为面扫描:四触达 + 标空封印负向兑付行在案) +func _polarity_probe_accepts(probe: Dictionary) -> bool: + if not (bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"])): + return false + if int(probe["mine_events"]) < 1 or int(probe["safe_events"]) < 1: + return false + var miss_rows := _rows_at(probe["rows"], "seal_miss", probe["miss_cell"]) + if miss_rows.is_empty(): + return false + for r in miss_rows: + if String(r.get("type", "")) == "seal_miss": + continue + var eff := String(r.get("effect_id", "")) + if eff != "" and not NEGATIVE_EFFECTS.has(eff): + return false + return true + + +# 极性路由探针驱动:标 1 面真雷封印(标中)+ 1 面安全格封印(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。 +# 收集全部 cell_revealed / seal_judged 事件与结算行;返回格子集合与封印格坐标。 +func _run_polarity_probe(seed_val: int) -> Dictionary: + _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断 + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var probe := {"rows": [], "safe_cells": {}, "mine_cells": {}, "hit_cell": [], "miss_cell": [], + "mine_events": 0, "safe_events": 0, "seal_hit_done": false, "seal_miss_done": false} + settlement.step_row.connect(func(row): probe["rows"].append(row)) + settlement.minesweeper.cell_revealed.connect(func(evt): + if String(evt.get("channel", "")) == "initial": + return + if evt.kind == "mine": + probe["mine_cells"][evt.cell] = true + probe["mine_events"] = int(probe["mine_events"]) + 1 + elif evt.kind == "safe": + probe["safe_cells"][evt.cell] = true + probe["safe_events"] = int(probe["safe_events"]) + 1) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 300, "hero_max_hp": 300}) + var ms = settlement.minesweeper + # 封印 1:真雷(标中 → 正向);封印 2:安全格(标空 → 负向) + var mine_t := _find_seal_target(ms, true) + var safe_t := _find_seal_target(ms, false) + if not mine_t.is_empty(): + if mine_t.hero_cell != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": mine_t.hero_cell}]) + ms.settle_batch([{"action": "seal_place", "cell": mine_t.seal_cell}]) + probe["hit_cell"] = [mine_t.seal_cell.x, mine_t.seal_cell.y] + probe["seal_hit_done"] = true + if not safe_t.is_empty(): + if safe_t.hero_cell != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": safe_t.hero_cell}]) + ms.settle_batch([{"action": "seal_place", "cell": safe_t.seal_cell}]) + probe["miss_cell"] = [safe_t.seal_cell.x, safe_t.seal_cell.y] + probe["seal_miss_done"] = true + # 挖到终局(封印挡链不影响 AutoPlayer——choose_dig 跳过封印格) + var out := {"result": "", "rounds": 0, "terminated": false, "stream": []} + var guard := 40 + while settlement.battle_result == "" and guard > 0: + guard -= 1 + _auto_play_round(settlement, out) + _db.difficulty.rows.clear() # 测试台账还原 + return probe + + +func _rows_at(rows: Array, source_class: String, cell) -> Array: + var out: Array = [] + for r in rows: + if String(r.get("source_class", "")) == source_class and r.get("origin_cell", [9, 9]) == cell: + out.append(r) + return out + + +# 负向字典逐型直验(类型化兑付行协议):找真实板上各型非封锁格,直调 _grant_routed 负向路由, +# 断言每型的兑付形态(enrage/off_guard/sapped 效果行、heal 直伤行、blast 警觉行、action 行动点排队)。 + + +func _smoke_clear_reward_chain() -> void: + print("--- 清图奖励·双份兑付链(完美封印 / 零兑付 / 第二份被拒 / 高光记实)---") + # ① 真实流完美封印:标中一面真雷封印 + 零误标 + 清全图 → grant_clear 身份(clear_blast)双份真实兑付 + var rows: Array = [] + var run := _run_full_clear(20260915, false, rows) + Kit.check("① 完美封印清图达成(%d 轮)" % int(run.rounds), bool(run.cleared)) + var dmg := [] + for r in rows: + if String(r.get("type", "")) == "damage" and String(r.get("source_class", "")) == "clear_reward": + dmg.append(r) + var hl := _rows_of_type(rows, "highlight") + _regular_clear_reward_rows = dmg.size() + Kit.check("① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" % dmg.size(), dmg.size() == 2) + if dmg.size() == 2: + Kit.check("① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)", String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份")) + Kit.check("④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" % hl.size(), hl.size() == 1 and int(hl[0].value) == 2) + if hl.size() == 1: + Kit.check("高光标记 = perfect_sweep", String(hl[0].get("highlight", "")) == "perfect_sweep") + # ②/③ 弱形式:正常战斗流不可达的零兑付分支(见函数头注) + _smoke_clear_reward_zero_paths() + + +# 弱形式登记:正常战斗流下"授段时刻无存活敌"不可达——敌全灭当轮判定段即判胜并终止战斗,而清图批在判定段之前, +# 恒有存活敌;故本组直调编排私有面构造状态,只断言"路径存在 + 行为符合契约"(构造手法见各行注释)。 + + +# 弱形式登记:正常战斗流下"授段时刻无存活敌"不可达——敌全灭当轮判定段即判胜并终止战斗,而清图批在判定段之前, +# 恒有存活敌;故本组直调编排私有面构造状态,只断言"路径存在 + 行为符合契约"(构造手法见各行注释)。 +func _smoke_clear_reward_zero_paths() -> void: + # ②-a 首份零兑付判定(无存活敌)→ first_ok=false + 未生效行 + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260915) + var s = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + s.step_row.connect(func(row): rows.append(row)) + Kit.check("零兑付构造战斗启动", s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"})) + for u in s._enemy_system.all_enemies(): + s._enemy_system.apply_damage(u, int(u.hp)) # 测试通道:构造"无存活敌"(弱形式——全灭而非 despawn) + var snap = s._reward_economy.grant_clear() + Kit.check("清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)", snap != null and String(snap.cell_type) == "blast") + rows.clear() + var first_ok: bool = s._grant_clear_identity(snap, 1, "(清图奖励)") + Kit.check("② 即时类首份零兑付(无存活敌)→ first_ok=false", not first_ok) + Kit.check("② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)", _has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast")) + # ②-b 高光守卫:perfect 谓词成立但首份零兑付 → 高光行不发(不记名义值) + var rng_b = load("res://core/rng_service.gd").new() + rng_b.set_master_seed(20260915) + var s_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b) + var rows_b: Array = [] + s_b.step_row.connect(func(row): rows_b.append(row)) + Kit.check("高光守卫构造战斗启动", s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"})) + for u in s_b._enemy_system.all_enemies(): + s_b._enemy_system.apply_damage(u, int(u.hp)) + s_b._perfect_hit_true = 1 # 测试通道:构造完美谓词成立(真值在正常流由封印判定累加) + s_b._perfect_hit_false = 0 + rows_b.clear() + s_b._grant_clear_reward(1) + Kit.check("② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案", _has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast")) + Kit.check("② 首份零兑付 → 高光行不发(不记名义值 2)", _rows_of_type(rows_b, "highlight").is_empty()) + # ③ 第二份被拒:唯一存活敌 HP ≤ 首份 aoe 量 → 首份击杀 → 第二份无存活敌(zero + 未生效行) + var rng2 = load("res://core/rng_service.gd").new() + rng2.set_master_seed(20260915) + var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2) + var rows2: Array = [] + s2.step_row.connect(func(row): rows2.append(row)) + Kit.check("第二份被拒构造战斗启动", s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"})) + var boss = s2._enemy_system.all_enemies()[0] + boss.hp = 1 # 测试通道:压低至首份 aoe 必杀(弱形式——正常流 Boss 血量不可控) + s2._perfect_hit_true = 1 + s2._perfect_hit_false = 0 + rows2.clear() + s2._grant_clear_reward(1) + Kit.check("③ 首份 aoe 真实兑付(击杀唯一存活敌)", _has_source_row(rows2, "damage", "clear_reward")) + Kit.check("③ 第二份被拒产出 reward_ineffective 行(零兑付)", _has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast")) + var hl2 := _rows_of_type(rows2, "highlight") + Kit.check("④ 高光行 value = 实际有效份数 1(不记名义值 2)", hl2.size() == 1 and int(hl2[0].value) == 1) + + +# 轮内刷新 provenance 守卫:刷新换来的图同轮清图不发清图奖励(_board_from_midround_refresh 守卫) + + +# 轮内刷新 provenance 守卫:刷新换来的图同轮清图不发清图奖励(_board_from_midround_refresh 守卫) +func _smoke_refresh_provenance_guard() -> void: + print("--- 轮内刷新 provenance 守卫(刷新图清图不发清图奖励)---") + var rows: Array = [] + var run := _run_full_clear(20260916, true, rows) + Kit.check("轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)", bool(run.refreshed)) + Kit.check("刷新图清图达成(%d 轮)" % int(run.rounds), bool(run.cleared)) + var cr := 0 + var hl := 0 + for r in rows: + if String(r.get("source_class", "")) == "clear_reward": + cr += 1 + if String(r.get("type", "")) == "highlight": + hl += 1 + Kit.check("刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" % cr, cr == 0) + Kit.check("刷新图清图 → 无高光行(守卫在位——实测 %d 行)" % hl, hl == 0) + Kit.check("对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" % _regular_clear_reward_rows, _regular_clear_reward_rows > 0) + + +# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ==================== +# 极性路由断言(战斗结算 验收):挖安全格/封印命中只出正向、挖危险格/标错封印只出负向、兑付行指回来源格。 +# 正向字典效果集 = {attack_up, ac_up, weaken} +# + spark_aoe/heal_minor(flat)+ gp_gain(GP 袋,escrow 缓冲路由键非增益容器行); +# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ alert 警觉(blast percent) +# + gp_loss(破财,escrow 缓冲路由键)。 + + +func _smoke_polarity_routing() -> void: + print("--- 极性路由(挖安全格正向 / 挖危险格负向 / 标中正向 / 标空负向)---") + # 战斗随机值面随全局序漂移——构造种子按行为面扫描(谓词见 _polarity_probe_accepts) + var probe := {} + var polarity_seed := -1 + for s in [20260918, 20260919, 20260920, 20260921, 42, 4242, 7, 100, 20260922, 123, 2024, 31337, 555, 1111, 2222, 20260923, 999, 777]: + probe = _run_polarity_probe(s) + if _polarity_probe_accepts(probe): + polarity_seed = s + break + Kit.check("极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed, polarity_seed > 0) + Kit.check("构造触达:标中真雷 + 标空安全格均入队", bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"])) + Kit.check("构造触达:挖到危险格 ≥1(%d)" % int(probe["mine_events"]), int(probe["mine_events"]) >= 1) + Kit.check("构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]), int(probe["safe_events"]) >= 1) + # 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集 + # (action 型排队行 effect_id=ap_delta 正负两极共用路由键——按行值符号辨极性:正向格恒 +power) + var safe_all_pos := true + for e in probe["safe_cells"].keys(): + var cell_arr: Array = [e.x, e.y] + var rows := _rows_at(probe["rows"], "safe_cell", cell_arr) + if rows.is_empty(): + safe_all_pos = false # 每个资源格必有 ≥1 兑付行(每格必有货 → 每格必有行) + for r in rows: + var eff := String(r.get("effect_id", "")) + if eff == "ap_delta": + if int(r.get("value", 0)) <= 0: + safe_all_pos = false + continue + if eff != "" and not POSITIVE_EFFECTS.has(eff): + safe_all_pos = false + Kit.check("挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" % probe["safe_cells"].size(), safe_all_pos) + # 挖到的危险格:只出负向——来源行必存在且 effect 行 ∈ 负向集(alert = blast 负向;heal 负向 = damage 行; + # action 型排队行同按行值符号辨极性:危险格恒 −power) + var mine_all_neg := true + for e in probe["mine_cells"].keys(): + var cell_arr: Array = [e.x, e.y] + var rows := _rows_at(probe["rows"], "mine_hit", cell_arr) + if rows.is_empty(): + mine_all_neg = false + for r in rows: + var eff := String(r.get("effect_id", "")) + if eff == "ap_delta": + if int(r.get("value", 0)) >= 0: + mine_all_neg = false + continue + if eff != "" and not NEGATIVE_EFFECTS.has(eff): + mine_all_neg = false + Kit.check("挖危险格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" % probe["mine_cells"].size(), mine_all_neg) + # 封印命中:该危险格正向兑付行(seal_hit 来源)+ 效果 ∈ 正向字典 + var hit_ok := false + for r in probe["rows"]: + if String(r.get("source_class", "")) == "seal_hit" and r.get("origin_cell", [0, 0]) == probe["hit_cell"]: + hit_ok = true + var eff := String(r.get("effect_id", "")) + if eff == "ap_delta": + if int(r.get("value", 0)) <= 0: + hit_ok = false + continue + if eff != "" and not POSITIVE_EFFECTS.has(eff): + hit_ok = false + Kit.check("封印命中 → 该危险格资源的正向兑付行(origin 指回危险格,效果 ∈ 正向字典;action 型按行值符号辨极性)", hit_ok) + # 条款3 条件二封印溢价断言(标中兑付行数值 = 同型正向 ×(1+δ)): + # δ 读 settlement.seal_hit_premium(125 provisional);九型正向行值全固定(唯一区间行 clear_blast + # 系清图槽不入格池)⇒ 逐型期望全确定。整数量级四舍五入域注记:ac_up/action 1 点量纲取整后不变 + # =整数粒度地板——等号压线判读在当量聚合面(audit 工具/registry clause3_inequality),非断言面。 + var prem_pct: int = int(_db.settlement.seal_hit_premium) + Kit.check("封印溢价配置在位(seal_hit_premium=125 provisional——形态 a 兑付溢价)", prem_pct == 125) + var prem_rows := 0 + var prem_ok := true + for r in probe["rows"]: + if String(r.get("source_class", "")) != "seal_hit" or String(r.get("highlight", "")) != "seal_hit": + continue + prem_rows += 1 + var rtype := String(r.get("type", "")) + var v := int(r.get("value", 0)) + var eff := String(r.get("effect_id", "")) + if rtype == "effect": + if eff == "gp_gain": + prem_ok = prem_ok and v == int(round(3 * prem_pct / 100.0)) # GP 袋 3→4 + elif eff == "ap_delta": + prem_ok = prem_ok and v == 1 # +1AP:1 点量纲(round(1.25)=1——整数粒度地板) + else: + var mag: int = int(_db.status_effect_row(eff).get("magnitude", -999)) + prem_ok = prem_ok and v == int(round(mag * prem_pct / 100.0)) # 增伤50→63/削弱65→81/护甲1→1 + elif rtype == "heal": + # 急救 2→3(clamp 封顶只降不升:满血走 reward_ineffective 不入本行——区间断言) + prem_ok = prem_ok and v >= 1 and v <= int(round(2 * prem_pct / 100.0)) + elif rtype == "damage": + var at := String(r.get("attack_type", "")) + if at == "": + prem_ok = prem_ok and v == int(round(1 * prem_pct / 100.0)) # 爆裂粉尘 1→1(群伤行值=表值直付) + else: + # 穿刺/魔法 2→3(行值=落地伤=基准+克制点数——反推基准核对;damage_floor 兜底下成立) + prem_ok = prem_ok and (v - int(r.get("effectiveness", 0))) == int(round(2 * prem_pct / 100.0)) + Kit.check("标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" % [prem_rows, prem_pct], prem_rows >= 1 and prem_ok) + # 标空不乘溢价:负向直伤行(反噬6/陷阱8/魔法反噬8 固定表值)零溢价污染——溢价值挂标中正向单点 + var miss_flat_ok := true + for r in _rows_at(probe["rows"], "seal_miss", probe["miss_cell"]): + if String(r.get("type", "")) == "damage": + miss_flat_ok = miss_flat_ok and int(r.get("value", 0)) in [6, 8] + Kit.check("标空惩罚零溢价(负向直伤行=固定表值原值——溢价只挂标中正向)", miss_flat_ok) + # 标空封印:归因行 + 该安全格负向兑付行(seal_miss 来源) + var miss_rows := _rows_at(probe["rows"], "seal_miss", probe["miss_cell"]) + var miss_neg_ok := false + for r in miss_rows: + if String(r.get("type", "")) == "seal_miss": + miss_neg_ok = true + else: + var eff := String(r.get("effect_id", "")) + if eff != "" and not NEGATIVE_EFFECTS.has(eff): + miss_neg_ok = false + Kit.check("标空封印 → 封印未中归因行 + 该安全格资源负向兑付(同格同批)", not miss_rows.is_empty() and miss_neg_ok) + # 抵消过滤既有语义:封印后被撤封印不判(发射面基线过滤)——由排雷玩法 冒烟与封印判定基线覆盖,此处核兑付行零泄漏 + var seal_srcs := {} + for r in probe["rows"]: + if String(r.get("source_class", "")) in ["seal_hit", "seal_miss"]: + seal_srcs[r.get("origin_cell")] = true + Kit.check("封印判定兑付仅封印判定批两封印在案(撤封印抵消过滤零泄漏——%d 格)" % seal_srcs.size(), int(seal_srcs.size()) <= 2) + + +# 极性路由探针驱动:标 1 面真雷封印(标中)+ 1 面安全格封印(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。 +# 收集全部 cell_revealed / seal_judged 事件与结算行;返回格子集合与封印格坐标。 + + +# ---- 封印不可逆四情况冒烟(禁撤封印 + 挖封印格兑付改判)---- +# 四情况:① 插危险格不动 = 标中奖励照常;② 插危险格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发; +# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。 +# 拒绝面:seal_pull 出目录(out_of_catalog);重复封印仍 seal_present 拒绝。 +# 场景 A(④ + 拒绝面):标安全格 → seal_pull 拒绝 → 重复封印拒绝 → 直接挖开 → 收轮。 +# 场景 B(②):标危险格 → 直接挖开 → 收轮。 +# 场景 C(①③):标安全格 + 标危险格 → 不挖收轮 → 标空/标中判定照常。 +func _smoke_seal_irreversible_quadrants() -> void: + print("--- 封印不可逆四情况(禁撤封印 + 挖封印格兑付改判)---") + # 场景 A:安全格直挖(情况④)+ seal_pull/重复封印拒绝面 + var rng_a = load("res://core/rng_service.gd").new() + rng_a.set_master_seed(20260919) + var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a) + var rows_a: Array = [] + settle_a.step_row.connect(func(row): rows_a.append(row)) + Kit.check("四情况 A:开局受理", settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + var ms_a = settle_a.minesweeper + var t_a := _find_seal_target(ms_a, false) + Kit.check("四情况 A:找到安全格封印目标", not t_a.is_empty()) + if not t_a.is_empty(): + if t_a.hero_cell != ms_a.hero_pos(): + ms_a.settle_batch([{"action": "move", "cell": t_a.hero_cell}]) + Kit.check("四情况 A:封印受理", ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}])[0].accepted) + var pull_out: Array = ms_a.settle_batch([{"action": "seal_pull", "cell": t_a.seal_cell}]) + Kit.check("四情况 A:seal_pull 出目录拒绝(撤封印退役)", not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog") + var dup_place: Array = ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}]) + Kit.check("四情况 A:重复封印仍被拒(seal_present)", not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "seal_present") + var dig_a: Array = ms_a.settle_batch([{"action": "dig", "cell": t_a.seal_cell}]) + Kit.check("四情况 A:封印格直接挖开被受理(不再须先撤封印)", not dig_a.is_empty() and bool(dig_a[0].get("accepted", false))) + Kit.check("四情况 A:封印随挖掘隐式退役(has_seal = false)", not ms_a.has_seal(t_a.seal_cell)) + ms_a.settle_batch([{"action": "stop"}]) + var target_a: Array = [t_a.seal_cell.x, t_a.seal_cell.y] + var miss_a := 0 + var grant_a := 0 + var void_a := 0 + for row in rows_a: + if row.get("origin_cell", [0, 0]) != target_a: + continue + if row.get("type", "") == "seal_miss" or String(row.get("source_class", "")) == "seal_miss": + miss_a += 1 + if String(row.get("source_class", "")) == "safe_cell" and row.get("type", "") != "reward_ineffective": + grant_a += 1 + if row.get("type", "") == "reward_ineffective": + void_a += 1 + Kit.check("四情况 A:标空判定零兑付(×%d——封印已擦除不入轮末基线)" % miss_a, miss_a == 0) + Kit.check("四情况 A:安全格正向兑付作废(×%d——零奖励)" % grant_a, grant_a == 0) + Kit.check("四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" % void_a, void_a == 1) + # 场景 B:危险格直挖(情况②) + var rng_b = load("res://core/rng_service.gd").new() + rng_b.set_master_seed(20260919) + var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b) + var rows_b: Array = [] + settle_b.step_row.connect(func(row): rows_b.append(row)) + Kit.check("四情况 B:开局受理", settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + var ms_b = settle_b.minesweeper + var t_b := _find_seal_target(ms_b, true) + Kit.check("四情况 B:找到危险格封印目标", not t_b.is_empty()) + if not t_b.is_empty(): + if t_b.hero_cell != ms_b.hero_pos(): + ms_b.settle_batch([{"action": "move", "cell": t_b.hero_cell}]) + Kit.check("四情况 B:封印受理", ms_b.settle_batch([{"action": "seal_place", "cell": t_b.seal_cell}])[0].accepted) + var dig_b: Array = ms_b.settle_batch([{"action": "dig", "cell": t_b.seal_cell}]) + Kit.check("四情况 B:封印危险格直接挖开被受理", not dig_b.is_empty() and bool(dig_b[0].get("accepted", false))) + Kit.check("四情况 B:危险格已揭示(挖雷本体照常)", ms_b.is_revealed(t_b.seal_cell) and ms_b.current_board().is_mine(t_b.seal_cell)) + ms_b.settle_batch([{"action": "stop"}]) + var target_b: Array = [t_b.seal_cell.x, t_b.seal_cell.y] + var mine_hit_b := 0 + var hit_b := 0 + for row in rows_b: + if row.get("origin_cell", [0, 0]) != target_b: + continue + if String(row.get("source_class", "")) == "mine_hit": + mine_hit_b += 1 + if row.get("type", "") == "seal_hit" or String(row.get("source_class", "")) == "seal_hit": + hit_b += 1 + Kit.check("四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" % mine_hit_b, mine_hit_b >= 1) + Kit.check("四情况 B:标中不发(×%d——封印已擦除不判)" % hit_b, hit_b == 0) + # 场景 C:不挖收轮(①③判定照常) + var rng_c = load("res://core/rng_service.gd").new() + rng_c.set_master_seed(20260919) + var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c) + var rows_c: Array = [] + settle_c.step_row.connect(func(row): rows_c.append(row)) + Kit.check("四情况 C:开局受理", settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + var ms_c = settle_c.minesweeper + var t_safe := _find_seal_target(ms_c, false) + var t_mine := _find_seal_target(ms_c, true) + Kit.check("四情况 C:安全格+危险格封印目标齐备", not t_safe.is_empty() and not t_mine.is_empty()) + if not t_safe.is_empty() and not t_mine.is_empty(): + for t in [t_safe, t_mine]: + if t.hero_cell != ms_c.hero_pos(): + ms_c.settle_batch([{"action": "move", "cell": t.hero_cell}]) + ms_c.settle_batch([{"action": "seal_place", "cell": t.seal_cell}]) + ms_c.settle_batch([{"action": "stop"}]) # 不挖收轮——两封印均在场基线 + var safe_c: Array = [t_safe.seal_cell.x, t_safe.seal_cell.y] + var mine_c: Array = [t_mine.seal_cell.x, t_mine.seal_cell.y] + var miss_c := 0 + var miss_neg_c := 0 + var hit_c := 0 + for row in rows_c: + if row.get("origin_cell", [0, 0]) == safe_c and row.get("type", "") == "seal_miss": + miss_c += 1 + if row.get("origin_cell", [0, 0]) == safe_c and String(row.get("source_class", "")) == "seal_miss" and row.get("type", "") != "seal_miss": + miss_neg_c += 1 + if row.get("origin_cell", [0, 0]) == mine_c and (row.get("type", "") == "seal_hit" or String(row.get("source_class", "")) == "seal_hit"): + hit_c += 1 + Kit.check("四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" % [miss_c, miss_neg_c], miss_c >= 1 and miss_neg_c >= 1) + Kit.check("四情况 C:标中奖励照常(×%d)" % hit_c, hit_c >= 1) + + +# ---- 试玩日志器冒烟(结算快照 JSONL + 诊断转储)---- + + +# 负向字典逐型直验(类型化兑付行协议):找真实板上各型非封锁格,直调 _grant_routed 负向路由, +# 断言每型的兑付形态(enrage/off_guard/sapped 效果行、heal 直伤行、blast 警觉行、action 行动点排队)。 +func _smoke_negative_dictionary() -> void: + print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---") + # 数据面:sapped 行在位(状态表七行 + effect_pool_map 映射在位) + var sapped: Dictionary = _db.status_effect_row("sapped") + Kit.check("sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)", + not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \ + and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \ + and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack") + Kit.check("effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)", String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense") + # 行为面:逐型负向兑付形态(真实板 + 真实路由路径) + # heal 权重低(无 heal 板概率 ≈1/3)——单板五型齐备不再必然: + # 本断言多板扫描,取第一块五型齐备的板做行为断言。 + var settlement = null + var board = null + var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——危险格可查) + var rows: Array = [] + for seed_i in range(6): + # 每板独立 rng 实例(复用实例重播种触发 set_master_seed 的 _counter==0 守卫; + # 单板 break 会掩盖该违例,多板扫描才暴露) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260919 + seed_i) + settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + rows = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_max_hp": int(_db.hero.max_hp)}): + continue + board = settlement.minesweeper.current_board() + var r0 := {} + for c in board.compute_r0(): + r0[c] = true + by_type = {} + for y in board.height: + for x in board.width: + var c := Vector2i(x, y) + if r0.has(c) or by_type.size() == 5: + continue + var ident = settlement.query_reward_identity(c) + # aux 身份不入五型轴(五型齐备=格面资源断言轴, + # aux 供给挤占不改变本断言语义;扫描排除 aux 身份格) + if ident != null and String(ident.cell_type) != RewardTable.TREASURE_ITEM_TYPE and not by_type.has(String(ident.cell_type)): + by_type[String(ident.cell_type)] = c + # break 条件=percent_map 三型齐备(aux 供给挤占下 weaken/defense 收集时机漂移, + # 直验可达性优先;五型多样性 check 保留不动) + if by_type.has("attack") and by_type.has("defense") and by_type.has("weaken"): + break + Kit.check("扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys()), by_type.size() == 5) + # attack/defense/weaken:负向字典行走 apply_effect(off_guard 走 ac 轴——兑付形态不变) + var percent_map := {"attack": "enrage", "defense": "off_guard", "weaken": "sapped"} + for ctype in percent_map.keys(): + if not by_type.has(ctype): + continue + rows.clear() + var snap: Dictionary = settlement._reward_economy.query_full_snapshot(by_type[ctype]) + settlement._grant_routed(snap, "negative", "mine_hit", by_type[ctype], 1) + var eff: String = percent_map[ctype] + Kit.check("%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, eff], _has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff)) + # heal:负向 = 主角直伤(表值;不经增益状态) + if by_type.has("heal"): + rows.clear() + var hp_before: int = int(settlement.presentation_snapshot().hero.hp) + var snap_h: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["heal"]) + settlement._grant_routed(snap_h, "negative", "mine_hit", by_type["heal"], 1) + var hp_after: int = int(settlement.presentation_snapshot().hero.hp) + var backlash_power: int = 0 + for row in _db.reward.rows: + if String(row.get("reward_id", "")) == "heal_backlash": + backlash_power = int(row.power) + Kit.check("heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after], hp_after == hp_before - backlash_power) + # blast:负向 = 警觉(敌方护甲等级 +1 percent 行) + if by_type.has("blast"): + rows.clear() + var snap_b: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["blast"]) + settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1) + var alert_ok: bool = false + for eff_a in settlement._status_effects.query_active("enemy"): + if String(eff_a.effect_id) == "alert": + alert_ok = true + Kit.check("blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)", alert_ok) + # action:负向 = 下轮行动点 −1 / 正向 = +1(排队,轮开始兑现;确定性直验不依赖板面摇号) + rows.clear() + var d0: int = settlement.action_economy.pending_ap_delta() + settlement._grant_routed({"reward_id": "ap_drain", "cell_type": "action", "polarity": "negative", "display_name": "迟滞", "power": 1}, "negative", "mine_hit", Vector2i(0, 0), 1) + Kit.check("action 型负向 → 下轮行动点 −1(排队)", settlement.action_economy.pending_ap_delta() == d0 - 1) + settlement._grant_routed({"reward_id": "ap_up", "cell_type": "action", "polarity": "positive", "display_name": "行动点", "power": 1}, "positive", "safe_open", Vector2i(0, 0), 1) + Kit.check("action 型正负抵消 → 排队净值归零", settlement.action_economy.pending_ap_delta() == d0) + + +# 资源面断言:chain/dig 事件恒携带 resource=true(危险格同权);initial 恒 false(封锁格); +# 封锁格查询 = null、危险格未开恒可查。 + + +# 资源面断言:chain/dig 事件恒携带 resource=true(危险格同权);initial 恒 false(封锁格); +# 封锁格查询 = null、危险格未开恒可查。 +func _smoke_resource_surface() -> void: + print("--- 格面资源资格面(危险格携带 / 封锁格无资源)---") + var run1 = _run_battle(1, 0) + var run2 = _run_battle(2, 0) + var all_events: Array = run1.events + run2.events + var digchain_bad := 0 + var initial_bad := 0 + var mine_with_resource := 0 + var initial_count := 0 + for evt in all_events: + if not evt.has("resource"): + continue # 非揭示事件(round_end 标记等) + if String(evt.get("channel", "")) == "initial": + initial_count += 1 + if bool(evt.resource): + initial_bad += 1 + else: + if not bool(evt.resource): + digchain_bad += 1 + if evt.kind == "mine": + mine_with_resource += 1 + Kit.check("chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" % digchain_bad, digchain_bad == 0) + Kit.check("initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" % initial_count, initial_bad == 0 and initial_count >= 1) + Kit.check("危险格揭示携带资源(mine ×resource=true %d——危险格不再恒无)" % mine_with_resource, mine_with_resource >= 1) + # 封锁格无身份 / 危险格未开恒可查(未开格恒可查——秘宝 桥接结构前提) + var rng3 = load("res://core/rng_service.gd").new() + rng3.set_master_seed(20260920) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + var board = settlement.minesweeper.current_board() + var r0_first: Variant = null + for c in board.compute_r0(): + r0_first = c + break + var mine_unrevealed = null + for c in board.mines.keys(): + if not settlement.minesweeper.is_revealed(c): + mine_unrevealed = c + break + Kit.check("封锁格(R₀)身份查询 = null(不入摇号清单)", settlement.query_reward_identity(r0_first) == null) + Kit.check("未开危险格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)", mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null) + + +# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。 diff --git a/game/tests/unit/battle_settlement/variant_placement_test.gd b/game/tests/unit/battle_settlement/variant_placement_test.gd new file mode 100644 index 00000000..ecf61af0 --- /dev/null +++ b/game/tests/unit/battle_settlement/variant_placement_test.gd @@ -0,0 +1,312 @@ +# tests/unit/battle_settlement/variant_placement_test.gd — 地形变异单元测试宿主(G6 第二宿主) +# 断言面 = 两套(批 B 断言迁移自 smoke_all.gd,G6 施工组): +# 地形变异 布置段(编排器战斗初始化第 4 步——门控一致性/布置域/数量公式/连通片上限/确定性;L1/L2 双迭代) +# 全局作用域变异(互锁负样本/门控三维/全局池空兜底回落/资源面偏斜守恒与合成序/快照投影不变) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(各套自建 RngService——源套掷点隔离形态,宿主不持统一流); +# 表加载失败不中止(沿被迁断言面的源语义——承 G2/G3/G4/G5 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/variant_placement_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[variant_placement_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排(基点 _run_core_smoke 调用序——本组套的相对序) + _smoke_variant_placement(1) + _smoke_variant_placement(2) + _smoke_global_variant() + quit(Kit.finish("variant_placement_test")) + + +# ---- 全局作用域变异 断言面(「全局池空 → region 兜底回落」路径 +# ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- + + +# ---- 全局作用域变异 断言面(「全局池空 → region 兜底回落」路径 +# ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- + +# 取号计数桩(variant_place 取号断言——子类拦截计数,派生与取数语义零改动) +class _CountVariantRng extends RngServiceCore: + var variant_calls := 0 + func open_stream(purpose: String) -> RandomNumberGenerator: + if purpose == "variant_place": + variant_calls += 1 + return super.open_stream(purpose) + + +# ---- 地形变异 布置段(棋盘设置顺序第 4 步断言面)---- +# 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异—— +# 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。 + +func _smoke_variant_placement(L: int) -> void: + print("--- 地形变异 布置段(L%d)---" % L) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}): + Kit.check("L%d 布置段战斗启动失败" % L, false) + return + var b = settlement.minesweeper.current_board() + Kit.check("L%d 布置段板非空" % L, b != null) + if b == null: + return + var pool_ids: Array = [] + for r in _db.variant_pool(L, String(b.board_type)): + pool_ids.append(String(r.variant_id)) + var vcells: Array = b.variants.keys() + var vid0 := "" + var single_type := true + var bad_id := false + for c in vcells: + var ids: Array = b.variants[c] + if ids.size() != 1: + single_type = false + for vid in ids: + if vid0 == "": + vid0 = String(vid) + elif vid0 != String(vid): + single_type = false + if not pool_ids.has(String(vid)): + bad_id = true + Kit.check("L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)], not bad_id) + Kit.check("L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L, single_type) + var r0 := {} + for c in b.compute_r0(): + r0[c] = true + var domain_ok := true + for c in vcells: + if r0.has(c) or b.is_mine(c): + domain_ok = false + Kit.check("L%d 变异格全在初始展开区外且不吃危险格(布置域结构保障——closed_only)" % L, domain_ok) + var cand_count := 0 + for c in b.safe_cells(): + if not r0.has(c): + cand_count += 1 + var expect_cap: int = int(round(float(cand_count) * float(_db.variation.percent_of_cells) / 100.0)) \ + + (L - 1) * int(_db.variation.per_level_bonus) + Kit.check("L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap], vcells.size() <= expect_cap) + # 数量公式量随候选池缩放:L1 无关卡加成,极小池(<10 格)可 round 至 0—— + # 零布置为合法态(同池空口径);非零断言为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0) + Kit.check("L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L, expect_cap == 0 or vcells.size() > 0) + var pack_max: int = int(_db.variation.pack_max) + var vset := {} + for c in vcells: + vset[c] = true + var seen := {} + var pack_ok := true + for c in vcells: + if seen.has(c): + continue + seen[c] = true + var comp_size := 1 + var q: Array = [c] + while q.size() > 0: + var x: Vector2i = q.pop_front() + for n in b.neighbors8(x): + if vset.has(n) and not seen.has(n): + seen[n] = true + comp_size += 1 + q.append(n) + if comp_size > pack_max: + pack_ok = false + Kit.check("L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max], pack_ok) + # 布置确定性:全新 RngService 同种子重开,变异布置逐格相同(variant_place 流确定性) + var rng2 = load("res://core/rng_service.gd").new() + rng2.set_master_seed(42) + var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2) + s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}) + var b2 = s2.minesweeper.current_board() + Kit.check("L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L, b2 != null and _variant_dump(b2) == _variant_dump(b)) + + +func _variant_dump(b) -> String: + var keys: Array = b.variants.keys() + keys.sort_custom(func(a, b2): return a.y * 1000 + a.x < b2.y * 1000 + b2.x) + var parts: Array = [] + for c in keys: + parts.append("%d,%d:%s" % [c.x, c.y, str(b.variants[c])]) + return "|".join(parts) + + +# ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ---- + + +func _smoke_global_variant() -> void: + print("--- 全局作用域变异(门控三维 + 全局池空兜底回落 + 资源面偏斜)---") + # ① 跨字段互锁负样本(resource_face ⟺ scope=global 三条 + effect_value 新域 [0,50]) + var vt = load("res://data/schemas/variation_table.gd").new() + vt.rows.assign([{"variant_id": "g1", "display_name": "负甲", "unlock_level": 2, "scope": "region", + "effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)", _errs_has_cross_field(vt.validate())) + vt.rows.assign([{"variant_id": "g2", "display_name": "负乙", "unlock_level": 2, "scope": "global", + "effect": "hide_number", "effect_value": 0, "trigger": "always", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)", _errs_has_cross_field(vt.validate())) + vt.rows.assign([{"variant_id": "g3", "display_name": "负丙", "unlock_level": 2, "scope": "global", + "effect": "resource_face", "effect_value": 30, "trigger": "on_open", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 整图恒在)", _errs_has_cross_field(vt.validate())) + vt.rows.assign([{"variant_id": "g4", "display_name": "负丁", "unlock_level": 2, "scope": "global", + "effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "closed_only", + "board_types": [], "counter_items": []}]) + Kit.check("互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)", _errs_has_cross_field(vt.validate())) + vt.rows.assign([{"variant_id": "g5", "display_name": "负戊", "unlock_level": 2, "scope": "global", + "effect": "resource_face", "effect_value": 51, "trigger": "always", "placement": "any", + "board_types": [], "counter_items": []}]) + var vterr: Array = vt.validate() + var domain_hit := false + for e in vterr: + if String(e.get("category", "")) == "domain": + domain_hit = true + Kit.check("互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)", domain_hit) + # ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错) + var pool_boss_ids: Array = [] + for r in _db.variant_pool(2, "underdark", "boss"): + pool_boss_ids.append(String(r.variant_id)) + Kit.check("门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)", pool_boss_ids == ["magma", "ash"]) + var pool_battle_ids: Array = [] + for r in _db.variant_pool(2, "underdark", "battle"): + pool_battle_ids.append(String(r.variant_id)) + Kit.check("门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)", pool_battle_ids == ["magma", "ash"]) + Kit.check("门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)", _db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2) + Kit.check("L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)", _db.variant_pool(1, "underdark", "boss").is_empty()) + Kit.check("全局决议:battle 节点恒无全局型", _db.global_variant_row(2, "underdark", "battle").is_empty()) + Kit.check("全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)", _db.global_variant_row(2, "underdark", "boss").is_empty()) + Kit.check("全局决议:L1 boss 全局池空 → 空行(兜底回落承载)", _db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty()) + Kit.check("elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)", _db.global_variant_row(2, "underdark", "elite").is_empty()) + # ③ 兜底回落:全局池空 → Boss 图改走 region 候选池(恰一行 region 变异 + 格子级布置 + 正常取号) + var crng = _CountVariantRng.new() + crng.set_master_seed(42) + var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng) + var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) + Kit.check("L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)", boss_ok) + var boss1_ids: Array = [] + var boss1_variants: Dictionary = {} + var boss1_calls := 0 + if boss_ok: + var bb = boss_settlement.minesweeper.current_board() + Kit.check("屏蔽后回落:Boss 图无整图全局型(board_variants 空——全局池空合法态)", bb.board_variant_ids().is_empty()) + var boss_vids := {} + for vc in bb.variants: + for vid in bb.variants[vc]: + boss_vids[String(vid)] = true + Kit.check("屏蔽后回落:Boss 图 region 变异恰一行且在候选池内(实测 %s)" % str(boss_vids.keys()), boss_vids.size() == 1 and (boss_vids.has("magma") or boss_vids.has("ash"))) + Kit.check("屏蔽后回落:Boss 图恢复 region 变异格布置(原替换制跳过 region 的逻辑随全局池空失效)", not bb.variants.is_empty()) + Kit.check("屏蔽后回落:variant_place 恢复取号(原全局取用零取号态消失)", crng.variant_calls > 0) + var snap: Dictionary = boss_settlement.presentation_snapshot() + Kit.check("快照投影:board_variants 空态一致(横幅/角标唯一读取面——无全局型即零预告)", _arr_str_eq(snap.get("board_variants", []), bb.board_variant_ids())) + boss1_ids = bb.board_variant_ids() + boss1_variants = bb.variants.duplicate() + boss1_calls = crng.variant_calls + # Boss 图确定性:同种子两遍逐位相同 + var crng2 = _CountVariantRng.new() + crng2.set_master_seed(42) + var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2) + if boss2.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): + var bb2 = boss2.minesweeper.current_board() + Kit.check("确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)", _arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls) + # ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序) + var eco = load("res://core/reward_economy.gd").new(_db, load("res://core/rng_service.gd").new()) + var base_rows: Array = eco._rollable_rows() + var base_sum: int = eco._pool_weight(base_rows) + Kit.check("偏斜基线:正向池现行合计 77(挖掘风险批口径)", base_sum == 77) + eco.board_skew = 0 + var off_rows: Array = eco._biased_cell_rows(0) # 内容数据驱动扩展批 T4:偏运权重改 reward_roll 订阅命中入参(luck_bias 注入位退役) + Kit.check("off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)", off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows)) + eco.board_skew = 30 + var on_rows: Array = eco._biased_cell_rows(0) + Kit.check("合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)", eco._pool_weight(on_rows) == 77) + var wmap := _rows_weight_map(on_rows) + Kit.check("白名单豁免:weaken/gp/action 三行偏斜后逐位不动", wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2) + Kit.check("生存族下调:heal 4→3 / defense 11→7(v=30 构造例)", wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7) + Kit.check("进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)", wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9) + eco.board_skew = 30 + var combo_rows: Array = eco._biased_cell_rows(2) # 件级加算 2=偏运硬币行 value(reward_roll bias_rewards 命中面同值) + var cw := _rows_weight_map(combo_rows) + Kit.check("合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8", eco._pool_weight(combo_rows) == 77 + 4 * 2) + Kit.check("合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)", cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11) + eco.board_skew = 50 + var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows(0)) + Kit.check("域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)", eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0) + # 普通图摇号全链路确定性(off 路径)——同种子普通图资源映射逐位相同(golden 对拍另有批前快照) + var nrng = load("res://core/rng_service.gd").new() + nrng.set_master_seed(42) + var normal1 = load("res://core/battle_orchestrator.gd").new(_db, nrng) + normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + var n1: Dictionary = _board_resource_dump(normal1) + var nrng2 = load("res://core/rng_service.gd").new() + nrng2.set_master_seed(42) + var normal2 = load("res://core/battle_orchestrator.gd").new(_db, nrng2) + normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + Kit.check("确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)", n1 == _board_resource_dump(normal2) and not n1.is_empty()) + + +func _arr_str_eq(a, b: Array) -> bool: + var arr: Array = a if a is Array else [] + if arr.size() != b.size(): + return false + for i in arr.size(): + if String(arr[i]) != String(b[i]): + return false + return true + + +func _rows_weights_equal(a: Array, b: Array) -> bool: + if a.size() != b.size(): + return false + for i in a.size(): + if String(a[i].get("reward_id", "")) != String(b[i].get("reward_id", "")) or int(a[i].weight) != int(b[i].weight): + return false + return true + + +func _rows_weight_map(rows: Array) -> Dictionary: + var m := {} + for r in rows: + m[String(r.get("reward_id", ""))] = int(r.weight) + return m + + +func _board_resource_dump(settlement) -> Dictionary: + var b = settlement.minesweeper.current_board() + var dump := {} + for c in b.variants.keys(): + dump["v:%d,%d" % [c.x, c.y]] = ",".join(b.variants[c]) + for y in b.height: + for x in b.width: + var ident = settlement._reward_economy.query_identity(Vector2i(x, y)) + if ident != null: + dump["r:%d,%d" % [x, y]] = String(ident.reward_id) + return dump + + +# ---- 地形变异 布置段(棋盘设置顺序第 4 步断言面)---- +# 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异—— +# 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。 + + +func _errs_has_cross_field(errs: Array) -> bool: + for e in errs: + if String(e.get("category", "")) == "cross_field": + return true + return false + + +# ---- 全局作用域变异 断言面(「全局池空 → region 兜底回落」路径 +# ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- + +# 取号计数桩(variant_place 取号断言——子类拦截计数,派生与取数语义零改动) diff --git a/game/tests/unit/contracts/source_guard_test.gd b/game/tests/unit/contracts/source_guard_test.gd new file mode 100644 index 00000000..a0105642 --- /dev/null +++ b/game/tests/unit/contracts/source_guard_test.gd @@ -0,0 +1,77 @@ +# tests/unit/contracts/source_guard_test.gd — 仓库自身源码契约单元测试宿主 +# 断言面 = 两套(批 B 断言迁移自 smoke_all.gd,G12 施工组;被测对象=仓库自身源文件, +# 主会话裁决五新设 source_guard 子目录归「契约」语义): +# 类型闸(project.godot warning 配置在位 + 审计器闸机接线) +# 卫生件 grep 闸(产品码字符串 Callable/死代码/GP 影子账残留零命中 + 层序落位单口) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;被测面是仓库文件直读 +# (FileAccess/DirAccess/project.godot 设置),零表夹具零 RNG。 +# 运行:godot --headless --path game --script res://tests/unit/contracts/source_guard_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + # 套执行序沿源编排序(_split_tail 尾段调用序) + _smoke_type_gate() + _smoke_hygiene_gates() + quit(Kit.finish("source_guard_test")) + + +# ---- 类型闸断言(V7——project.godot warning 配置在位〔源码断言〕+审计器脚本在位; +# 审计器归零判定走 static_scan S9 闸机,此处兜配置面)---- +func _smoke_type_gate() -> void: + var pj: String = FileAccess.get_file_as_string("res://project.godot") + Kit.check("V7 类型闸 warning 配置在位(untyped_declaration=1 显形)〔迭代27 T5〕", + pj.contains("gdscript/warnings/untyped_declaration=1")) + var scan_src: String = FileAccess.get_file_as_string("res://tests/static_scan.sh") + Kit.check("V7 类型审计器闸机在位(static_scan S9 → tools/audit_type_hints.py)〔迭代27 T5〕", + scan_src.contains("audit_type_hints.py")) + + +# ---- 卫生件 grep 闸(V8——死代码/字符串 Callable/影子账残留零命中;源码扫描面)---- +func _smoke_hygiene_gates() -> void: + var callables := 0 + for d in ["res://core", "res://presentation", "res://data"]: + var dir := DirAccess.open(d) + if dir == null: + continue + dir.list_dir_begin() + var f := dir.get_next() + while f != "": + if dir.current_is_dir() and not f.begins_with("."): + var sub := DirAccess.open(d + "/" + f) + if sub != null: + sub.list_dir_begin() + var g := sub.get_next() + while g != "": + if g.ends_with(".gd"): + var t: String = FileAccess.get_file_as_string(d + "/" + f + "/" + g) + callables += t.count("Callable(self, \"") + g = sub.get_next() + elif f.ends_with(".gd"): + var t2: String = FileAccess.get_file_as_string(d + "/" + f) + callables += t2.count("Callable(self, \"") + f = dir.get_next() + Kit.check("V8 字符串 Callable 产品码零命中(实测应 0——方法引用换装闸)〔迭代27 T6〕", + callables == 0) + Kit.check("V8 FeatsEngine 整文件退役(class_name 注销=文件删除)〔迭代27 T6〕", + not FileAccess.file_exists("res://core/feats_engine.gd")) + var bo: String = FileAccess.get_file_as_string("res://core/battle_orchestrator.gd") + Kit.check("V8 死代码抽查·_attune_row 零残留〔迭代27 T6〕", + not bo.contains("func _attune_row")) + var sm: String = FileAccess.get_file_as_string("res://core/save_manager.gd") + Kit.check("V8 死代码抽查·attunement_counters 零残留〔迭代27 T6〕", + not sm.contains("func attunement_counters")) + var mv: String = FileAccess.get_file_as_string("res://presentation/main.gd") + Kit.check("V8 GP 影子账残留零命中(main 源码面——文件已删+委托缝已拆)〔迭代27 T3〕", + not mv.contains("gp_shadow_ledger") and not mv.contains("_gp_ledger")) + # V1d 层序落位单口:main.gd 的 move_child 调用全部收在 _apply_layer_contract 内(合同装配单口—— + # 函数体之外零调用点;出现第二处落位=硬编码层序,须改合同表而非加调用) + var fn_start: int = mv.find("func _apply_layer_contract") + var fn_end: int = mv.find("\nfunc ", fn_start + 10) + if fn_end < 0: + fn_end = mv.length() + var outside_calls: int = (mv.substr(0, fn_start) + mv.substr(fn_end)).count("move_child(") + Kit.check("V1d 层序落位单口(_apply_layer_contract 之外 move_child 调用点=%d 应=0)〔装配生命批 V1〕" % outside_calls, + outside_calls == 0) diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd new file mode 100644 index 00000000..1d606162 --- /dev/null +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -0,0 +1,820 @@ +# tests/unit/core/adventure_feats_test.gd — 冒险结构与专长单元测试宿主 +# 断言面 = 三套(批 B 断言迁移自 smoke_all.gd,G7 施工组): +# 冒险结构 API 闸(状态机受理面——重复开始拒绝/入口停点/放弃两态) +# 冒险验收断言(三关序列/大奖励三层兑付/账本封闭/败北线/恢复数学/加算区同账/共享池) +# 专长重设计实装(feat_table 四级/案D 池接线/V3 订阅面逐件三列/专长凭证保底) +# 注:断点往返三条(基点 1420–1426)按主会话裁决②改判集成宿主,不在本宿主断言面内。 +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(_fresh_rng 同种子隔离夹具——RngService 构造注入 runner); +# 表加载失败不中止(沿被迁断言面的源语义——承 G2–G6 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/core/adventure_feats_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[adventure_feats_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排(基点 _run_core_smoke 调用序——本组套的相对序) + _smoke_adventure_api() + _smoke_adventure() + _smoke_feats() + quit(Kit.finish("adventure_feats_test")) + + +# ---- 冒险结构冒烟(AdventureTable 建表 + AdventureRunner + 冒险验收断言)---- + +# 令牌候选匹配(TP0——kind+tier 复合身份;冒烟断言面共用) +func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Dictionary: + for t in candidates: + var tok: Dictionary = t as Dictionary + if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier: + return tok + return {} + + +# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态) + + +# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态) +func _smoke_adventure_api() -> void: + print("--- 冒险结构 API 闸(重复开始拒绝 · 入口停点 · 放弃两态受理)---") + var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42)) + adr.set_hero_override(900, 400) # 首战速胜档(放弃的非战斗受理要走到选路态) + # 入口停点观测(信号序 + 入口候选快照——容器写回规避 lambda 按值捕获) + var sig_seq: Array = [] + var obs := {"entry_cands": []} + adr.level_started.connect(_api_mark_started.bind(sig_seq)) + adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs)) + adr.node_entered.connect(_api_mark_node.bind(sig_seq)) + Kit.check("冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)", adr.start_adventure(42, {"first_script": true})) + Kit.check("进行中重复 start_adventure 拒绝(重开须待结算完毕)", not adr.start_adventure(43)) + Kit.check("停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)", sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented") + Kit.check("入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)", + adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \ + and adr.runner().current_battle() == null) + Kit.check("pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)", adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0]))) + Kit.check("pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)", sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null) + Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not adr.abandon()) + # 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理 + var autoplay = load("res://smoke/level_autoplay.gd") + var battle = adr.runner().current_battle() + var st := _battle_st("normal") + var guard := 30 + while adr.runner().current_battle() == battle and guard > 0: + guard -= 1 + autoplay._autoplay_battle(adr.runner(), battle, st, null) + Kit.check("首战胜后选路态(非战斗期——current_battle 置空)", adr.runner().current_battle() == null and adr.runner().awaiting_choice()) + Kit.check("非战斗期 abandon 受理(选路态——同失败型结算)", adr.abandon()) + var sum: Dictionary = adr.summary + Kit.check("放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1)), String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true))) + Kit.check("结算后重复 abandon 拒绝(结算受理只算一次——state=idle)", not adr.abandon()) + + +# 观测桩(信号序采集——bind 容器写回,签名按各信号实参定制) + + +# 观测桩(信号序采集——bind 容器写回,签名按各信号实参定制) +func _api_mark_started(_li: int, _dn: String, seq: Array) -> void: + seq.append("level_started") + + +func _api_mark_choices(cands: Array, seq: Array, obs: Dictionary) -> void: + seq.append("choices_presented") + obs["entry_cands"] = cands.duplicate() + + +func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void: + seq.append("node_entered") + + +# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/ +# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃) + + +# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/ +# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃) +func _smoke_adventure() -> void: + print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---") + var autoplay = load("res://smoke/level_autoplay.gd") + # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言; + # enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域, + # 同带多行合法,带键聚合天然兼容〔fv24〕) + Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.level.rows.size()) == 3) + var bg_keys := {} + for row in _db.board_gen.rows: + bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var en_keys := {} + for row in _db.enemy.rows: + en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true + var miss := 0 + for row in _db.level.rows: + for n in _level_active_nodes(int(row.level_index)): + var nt := String(n.get("type", "")) + if nt != "battle" and nt != "boss": + continue + var key := "%d_%s_%d" % [int(row.level_index), nt, int(n.get("node_depth", -1))] + if not bg_keys.has("%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]) or not en_keys.has(key): + miss += 1 + Kit.check("行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss, miss == 0) + # ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复) + var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) + var sum: Dictionary = st["summary"] + Kit.check("满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)", String(st["result"]) == "completed" and bool(sum.get("cleared", false))) + Kit.check("通关几关 = 3(levels_cleared)", int(sum.get("levels_cleared", -1)) == 3) + var ks_exp := 0 + for li in range(1, int(_db.level.rows.size()) + 1): + ks_exp += int(_db.meta_boss_keystone_row(li).get("first_kill", 0)) + Kit.check("钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)), int(sum.get("keystone_total", -1)) == ks_exp) + var drops: Array = sum.get("keystone_drops", []) as Array + Kit.check("逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)", drops.size() == 3) + Kit.check("摘要携带主种子(42)", int(sum.get("master_seed", -1)) == 42) + var picks: Array = [] + var resolves: Array = [] + var node_picks := 0 + for ev in (st["reward_events"] as Array): + if (ev as Dictionary).has("curio_pick"): + picks.append((ev as Dictionary)["curio_pick"]) + elif (ev as Dictionary).has("node_curio_pick"): + node_picks += 1 # 奖励节点三选一(每关奖励节点各 1 次) + elif (ev as Dictionary).has("curio_resolved"): + resolves.append((ev as Dictionary)["curio_resolved"]) + Kit.check("通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)", picks.size() == 2 and resolves.size() == 2 + node_picks) + Kit.check("奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks, node_picks >= 1) + var node_resolves := 0 + for r0 in resolves: + if String((r0 as Dictionary).get("context", "")) == "reward_node": + node_resolves += 1 + Kit.check("奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [node_resolves, node_picks], node_resolves == node_picks) + var level_resolves: Array = [] + for r0 in resolves: + if String((r0 as Dictionary).get("context", "")) != "reward_node": + level_resolves.append(r0) + var heal_ok := picks.size() == 2 + var cand_ok := picks.size() == 2 + var ks_gain_ok := picks.size() == 2 + var resolve_ok := level_resolves.size() == 2 + var max_flat := int(_db.adventure.curio_value_max_hp_common) + var heal_pct := int(_db.adventure.clear_heal_pct) + var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计) + var ks_prefix := 0 + for i in picks.size(): + var p: Dictionary = picks[i] + var cands: Array = p.get("candidates", []) as Array + if cands.size() < 2 or cands.size() > 3: + cand_ok = false + var seen := {} + for c in cands: + # 身份键 = kind|tier|feat_id(TP0 v2.2 relic 按 feat_id 分列 + # 不参与同 kind 去重〔_token_identity relic: 前缀〕,同 tier 三枚不同流派件=三个不同选项合法态) + var ckey := "%s|%s|%s" % [String((c as Dictionary).get("kind", "")), String((c as Dictionary).get("tier", "")), String((c as Dictionary).get("feat_id", ""))] + if seen.has(ckey): + cand_ok = false + seen[ckey] = true + var heal: int = int(p.get("heal_gained", -1)) + var hp_before: int = int(p.get("hp_before", -1)) + var hp_after: int = int(p.get("hp_after", -1)) + # 账本生效上限口径:置满/回血封顶按生效上限(flat 后乘调试浮窗 pct)——本段 pct=400 + # 加算区 = 树/锻造件(hp_acc)+购血观测值(level_autoplay 停点快照 bonus_max_hp)——精确断言不再窗口拟合 + var extra_obs := int(p.get("bonus_max_hp", -1)) + var m_obs := int(round(float(int(_db.hero.max_hp) + hp_acc + extra_obs) * 5.0)) + var heal_exp := mini(int(floor(float(m_obs) * float(heal_pct) / 100.0)), m_obs - hp_before) + if extra_obs < 0 or heal != heal_exp or hp_after != hp_before + heal or hp_after > m_obs: + heal_ok = false + ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) + if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: + ks_gain_ok = false + if resolve_ok: + var r: Dictionary = level_resolves[i] + var rk := String(r.get("picked", "")) + var rt := String(r.get("tier", "")) + if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.adventure.curio_value(rk, rt)): + resolve_ok = false + if rk == "max_hp": + hp_acc += int(r.get("value", max_flat)) + Kit.check("三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)", cand_ok) + Kit.check("通关部分回血封顶(floor(观测上限×比例) 封顶——购血观测值精确断言,核代码增补)", heal_ok) + Kit.check("钥石逐杀入账(首杀档位 + 累计逐位)", ks_gain_ok) + Kit.check("点选恰兑付选中一件(值=令牌档位表值;未选项零生效)", resolve_ok) + var seq: Array = st["encounter_max_hp_seq"] + var k := 5.0 # hp+400 ⇒ 调试浮窗百分比后乘 ×5(加算先加、百分比后乘——生效次序口径) + var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k))) + # 模拟档 v3.4 商店贪心购血合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量; + # 大生命 +10(优秀档)——上界按大件最坏构成 3×10+购血 15=+45(TP0 放宽承袭) + var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * int(_db.adventure.curio_value("max_hp", "excellent")) + 15) * k))) + var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base + for v in (seq as Array): + if int(v) < exp_base or int(v) > hp_ceiling: + seq_ok = false + Kit.check("各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % exp_base, seq_ok) + # ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致) + var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) + Kit.check("同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)", + st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq + and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"]) + # ⑤ 败北线(零配 L1 败北 + 半配 L2 败北——种子扫描,仿商店房 range(42,92) 先例) + var lose := {} + for s in range(42, 92): + var stl: Dictionary = autoplay.run_adventure(_db, s, 0, 0) + if String(stl["result"]) == "failed" and int(stl["summary"].get("stopped_at", -1)) == 1: + lose = {"s": s, "st": stl} + break + Kit.check("零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1)), not lose.is_empty()) + if not lose.is_empty(): + var sl: Dictionary = lose["st"]["summary"] + Kit.check("L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层", + not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \ + and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1) + var dl: Dictionary = sl.get("defeat", {}) + Kit.check("L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)", + int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \ + and String(dl.get("node_type", "")) in ["battle", "boss"]) + var half := {} + for s in range(42, 142): + var sth: Dictionary = autoplay.run_adventure(_db, s, 50, 0) + if String(sth["result"]) == "failed" and int(sth["summary"].get("stopped_at", -1)) == 2: + half = {"s": s, "st": sth} + break + Kit.check("半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1)), not half.is_empty()) + if not half.is_empty(): + var sh: Dictionary = half["st"]["summary"] + Kit.check("L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层", + not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \ + and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \ + and int(sh["stopped_at"]) == 2) + var dh: Dictionary = sh.get("defeat", {}) + Kit.check("L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)", + int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \ + and String(dh.get("node_type", "")) in ["battle", "boss"]) + # ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化) + var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7)) + adr6.set_hero_override(900, 400) + Kit.check("重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)", adr6.start_adventure(7, {"first_script": true})) + adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 入口停点 pick 进首战 + var b6 = adr6.runner().current_battle() + var st6 := _adv_st() + var g6 := 30 + while adr6.runner().current_battle() == b6 and g6 > 0: + g6 -= 1 + autoplay._autoplay_battle(adr6.runner(), b6, st6, null) + Kit.check("重复拒绝场景就位:选路态放弃 → 已结算", adr6.abandon()) + var sum6_before: Dictionary = adr6.summary.duplicate(true) + adr6._on_level_finished({"result": "victory", "hp": 10, "node_index": 1, "node_id": 1, "node_type": "battle"}) + Kit.check("重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)", adr6.summary == sum6_before) + # ⑦ GP 跨关延续(L1 收束无 clear · 每战入账恰一次 · L2 商店房余额 > 0 · 冒险收束 clear 恰一次为末事件) + var wallet: Array = st["wallet_events"] + var clears := 0 + var gains := 0 + for ev in wallet: + if String(ev["event"]) == "clear": + clears += 1 + elif String(ev["event"]) == "gain" and String(ev.get("source", "")) != "curio_pick": + gains += 1 # 战斗完成入账计数(三选一 GP 袋件入账另有 source 标记——不计战斗口径) + Kit.check("每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])], gains == int(st["battles"])) + Kit.check("钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)", clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear") + var shop_seq: Array = st["shop_gp_seq"] + Kit.check("L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 2 and int(shop_seq[0]) > 0) + # ⑧ 加算区同账(手工场景:L2 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶) + var same := {} + for s in range(42, 92): + var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s)) + a8.set_hero_override(900, 400) + if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图 + continue + if _adv_drive_to_shop(a8, _adv_st(), autoplay): + same = {"a": a8, "s": s} + break + Kit.check("同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1)), not same.is_empty()) + if not same.is_empty(): + var a8 = same["a"] + var r8 = a8.runner() + var gp0: int = int(r8.gp) + var hp_price := -1 + for p in _db.shop.products: + if String(p.get("product_id", "")) == "hp_up": + hp_price = int(p.get("base_price", -1)) + var l2_price_bonus := 0 + for pb in _db.shop.price_level_bonus: + if int(pb.get("level_index", -1)) == 2: + l2_price_bonus = int(pb.get("bonus", 0)) + Kit.check("进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus], gp0 >= hp_price + l2_price_bonus) + Kit.check("购买 hp_up 受理(出账 + 加算区)", r8.buy_product("hp_up")) + var acc_shop := int(a8.run_ledger.bonus.get("max_hp", -1)) # L1 三选一后 + 商店 3(首件随种子) + Kit.check("统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop, acc_shop >= 3) + a8._on_level_finished({"result": "victory", "hp": int(r8.hero_hp), "node_index": r8.node_index, + "node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟 L2 通关(victory 载荷直调——非末关) + Kit.check("L2 通关三选一停点在案(直调兑付前——零自动推进)", not a8._curio_pending.is_empty()) + Kit.check("未兑付推进拒绝(选择停点零自动推进契约)", not a8.continue_to_next_level()) + var l2_cands: Array = a8._curio_pending["candidates"] + var l2_pick: Dictionary = l2_cands[0] + Kit.check("三选一点选兑付受理(候选取首件)", a8.pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", "")), String(l2_pick.get("feat_id", "")))) + var picked2: String = String(a8._curio_result.get("picked", "")) + var expect_acc: int = acc_shop + (int(a8._curio_result.get("value", 0)) if picked2 == "max_hp" else 0) + Kit.check("三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.run_ledger.bonus.get("max_hp", -1)), acc_shop, picked2], int(a8.run_ledger.bonus.get("max_hp", -1)) == expect_acc) + var max_eff8: int = a8.effective_max_hp() # 账本生效上限含调试浮窗 pct(a8 已 set 400) + Kit.check("通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8], int(a8.ledger["hero_hp"]) <= max_eff8) + Kit.check("兑付后推进受理(下一关开始)", a8.continue_to_next_level()) + # ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致) + var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99)) + Kit.check("恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法", + int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \ + and int(a9.apply_rest(30, 100, 100)) == 0) + var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99)) + Kit.check("同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)", int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5) + # ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算) + var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15)) + a10.set_hero_override(900, 400) + Kit.check("放弃场景:冒险启动(seed 15——固定模板 0 同上)", a10.start_adventure(15, {"first_script": true})) + Kit.check("放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)", a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0]))) + Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not a10.abandon()) + var b10 = a10.runner().current_battle() + var st10 := _adv_st() + var g10 := 30 + while a10.runner().current_battle() == b10 and g10 > 0: + g10 -= 1 + autoplay._autoplay_battle(a10.runner(), b10, st10, null) + Kit.check("非战斗期(选路态)abandon 受理", a10.runner().current_battle() == null and a10.abandon()) + # ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现; + # 无种类次数上限,「达上限剔除」语义由牌库耗尽承载) + var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11)) + Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}})) + for di in range(a11._curio_deck.size() - 1, -1, -1): + var dk := String((a11._curio_deck[di] as Dictionary).get("kind", "")) + if dk == "attack" or dk == "ap": + a11._curio_deck.remove_at(di) + var draw: Array = a11._draw_curio_candidates() + var draw_kinds := {} + for t in draw: + draw_kinds[String((t as Dictionary).get("kind", ""))] = true + Kit.check("池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw), not draw_kinds.has("attack") and not draw_kinds.has("ap")) + Kit.check("池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)", (draw as Array).size() >= 2) + # 池收缩(aux 不入三选一牌库:重复抽样零 aux 候选;同调位替换两路 + # white-box 测试属停点渠道,零投放零行为) + var treasure_seen := false + for i in 40: + for t in a11._draw_curio_candidates(): + if String((t as Dictionary).get("kind", "")) == "treasure_item": + treasure_seen = true + break + Kit.check("池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)", not treasure_seen) + # 满血回 0 合法(通关回血封顶口径) + var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12)) + a12.start_adventure(12, {"first_script": true}) + var cap := {"payload": {}} + a12.curio_choice_presented.connect(func(p): cap["payload"] = p) + var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0)) + a12.ledger["hero_hp"] = max_full + a12._on_level_finished({"result": "victory", "hp": max_full, "node_index": 1, "node_id": 1, "node_type": "shop"}) + Kit.check("满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)", int(cap["payload"].get("heal_gained", -1)) == 0) + # ⑬ 奖励节点共享池(同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现) + var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13)) + Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}})) + for di13 in range(a13._curio_deck.size() - 1, -1, -1): + if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack": + a13._curio_deck.remove_at(di13) + var ndraw: Dictionary = a13.node_curio_candidates() + var ncands13: Array = ndraw.get("candidates", []) as Array + var has_attack13 := false + for t in ncands13: + if String((t as Dictionary).get("kind", "")) == "attack": + has_attack13 = true + break + Kit.check("奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13), not has_attack13) + a13.present_node_curio(ncands13) + a13.present_node_curio([{"kind": "gp", "tier": "common"}]) + var gp13 := int(a13.ledger.get("gp", 0)) + Kit.check("奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")), a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common"))) + Kit.check("节点停点态兑付后清零(恰一次语义同构)", a13._node_curio_pending.is_empty()) + var s10: Dictionary = a10.summary + Kit.check("放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)", String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true))) + + +# ---- 专长冒烟(feats 四级 · 案D 池接线 · V3 订阅面逐件三列)---- + + +# ---- 专长冒烟(feats 四级 · 案D 池接线 · V3 订阅面逐件三列)---- + +func _smoke_feats() -> void: + print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---") + # ① 现行表牌库构成:30 件全量入池——102 数值令牌+30 relic 令牌(四级 rarity 直映射) + Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30) + Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty()) + var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) + Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71, {"first_script": true})) + var feat_tokens := 0 + var tier_counts := {} + for t in a_r0._curio_deck: + if String((t as Dictionary).get("kind", "")) == "relic": + feat_tokens += 1 + var tk := String((t as Dictionary).get("tier", "")) + tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1 + Kit.check("专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕", feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0._curio_deck.size() == 132) + # ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查) + Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty()) + Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty()) + # ③ 契约面静态钉扎(V18/V19/V21/V22——人工核对 V20/V21 裁决面以外的可机检半) + var kinds_ok := true + for t in a_r0._curio_deck: + var kk := String((t as Dictionary).get("kind", "")) + if kk != "relic" and not _db.adventure.CURIO_KINDS.has(kk): + kinds_ok = false + Kit.check("层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕", kinds_ok) + var rewrite_rows := 0 + for row_v in (_db.feats.rows as Array): + for tv in ((row_v as Dictionary).get("triggers", []) as Array): + if String(((tv as Dictionary).get("do", {}) as Dictionary).get("kind", "")) == "rewrite_board": + rewrite_rows += 1 + Kit.check("棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕", rewrite_rows == 0) + var bad_words := 0 + for row_v in (_db.feats.rows as Array): + var r_d: Dictionary = row_v as Dictionary + for txt_v in [String(r_d.get("display_name", "")), String(r_d.get("desc", ""))]: + if String(txt_v).contains("雷") or String(txt_v).contains("旗"): + bad_words += 1 + Kit.check("对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕", bad_words == 0) + Kit.check("数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕", + float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 + and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 + and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3) + # ④ 临时表注入(新 schema 形态——零 archetype/cap 直用):5 行 relic 入牌库/兑付/踢池/断点往返 + var rt := FeatTable.new() + rt.rows = [ + {"feat_id": "x1", "display_name": "试一", "rarity": "common", "desc": "测试揭示", "cap": 1, + "triggers": [{"on": "mine_hit", "do": {"kind": "reveal_tiles", "value": 1}}]}, + {"feat_id": "x2", "display_name": "试二", "rarity": "excellent", "desc": "测试回复", "cap": 1, + "triggers": [{"on": "mine_hit", "do": {"kind": "heal", "value": 2}}]}, + {"feat_id": "x3", "display_name": "试三", "rarity": "rare", "desc": "测试群伤", "cap": 1, + "triggers": [{"on": "seal_hit", "do": {"kind": "open_ticket", "channel": "blast", "value": 3}}]}, + {"feat_id": "x4", "display_name": "试四", "rarity": "rare", "desc": "测试减伤", "cap": 2, + "triggers": [{"on": "damage_taken", "when": [{"fact": "hp_ratio", "op": "lte", "value": 50}], "do": {"kind": "damage_reduce", "value": 2}}]}, + {"feat_id": "x5", "display_name": "试五", "rarity": "legendary", "desc": "测试AC", "cap": 1, + "triggers": [{"on": "seal_hit", "do": {"kind": "ac_bonus", "value": 1, "mode": "round"}}]}, + ] + Kit.check("新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)", rt.validate().is_empty()) + var live_rows: FeatTable = _db.feats + _db.feats = rt + var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) + Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72, {"first_script": true})) + var deck_relic := 0 + for t in a_r._curio_deck: + if String((t as Dictionary).get("kind", "")) == "relic": + deck_relic += 1 + Kit.check("专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=107", deck_relic == 5 and a_r._curio_deck.size() == 107) + # 渠道白名单断言(渠道轴定位:通关渠道只出专长/节点渠道零专长) + var feat_seen: Dictionary = {} + var node_relic_seen := false + for i in 60: + for t in a_r._draw_curio_candidates("level_clear"): + if String((t as Dictionary).get("kind", "")) == "relic": + feat_seen[String(t.get("feat_id", ""))] = true + for t in a_r._draw_curio_candidates("reward_node"): + if String((t as Dictionary).get("kind", "")) == "relic": + node_relic_seen = true + Kit.check("通关渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)", feat_seen.size() >= 2) + Kit.check("节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)", not node_relic_seen) + # T5 专长凭证行为断言(A5 拿到即开 / A6 保底计数——契约④四口径;独立 runner 防污染 a_r 牌库) + var a_t = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(91)) + a_t.start_adventure(91, {"first_script": true}) + var stock_before := {} + for kk in ["attack", "gp", "heal"]: + stock_before[kk] = a_t.pool_stock(kk) + a_t._node_curio_pending = {"candidates": [{"kind": "feat_ticket", "tier": "excellent"}, {"kind": "attack", "tier": "common"}]} + Kit.check("A5 专长凭证兑付受理(节点渠道——拿到即开)", a_t.apply_node_curio("feat_ticket", "excellent")) + var tkt_cands: Array = (a_t.node_curio_pending_state().get("candidates", []) as Array) + var tkt_all_relic := tkt_cands.size() > 0 + for t in tkt_cands: + if String((t as Dictionary).get("kind", "")) != "relic": + tkt_all_relic = false + Kit.check("A5 拿到即开——停点候选已替换为专长三选一(全 relic)", tkt_all_relic) + Kit.check("A5 零摘牌——专长凭证兑付不动令牌库(不进持有集)", a_t.pool_stock("attack") == int(stock_before["attack"]) and a_t.pool_stock("gp") == int(stock_before["gp"])) + Kit.check("A5 无专长凭证残留持有集", not (a_t.feats_held() as Array).has("feat_ticket")) + var feats_n := (a_t.feats_held() as Array).size() + a_t.apply_node_curio("relic", String((tkt_cands[0] as Dictionary).get("tier", "rare")), String((tkt_cands[0] as Dictionary).get("feat_id", ""))) + Kit.check("A5 专长面兑付受理(专长凭证开出候选可正常入持有集)", (a_t.feats_held() as Array).size() == feats_n + 1) + # A6 保底:pity 直填 5 → 下次展示必出(第 6 次必出口径);限购满 → 恒不触发 + a_t._feat_ticket_sold = 0 # A5 已兑付一枚专长凭证(sold=1=limit)——A6 段重置计数隔离验证 + a_t._feat_ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + var rg_t: RandomNumberGenerator = a_t.rng.open_stream("reward_pick") + Kit.check("A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)", a_t._feat_ticket_consider(rg_t)) + Kit.check("A6 触发后清零", a_t._feat_ticket_pity == 0) + a_t._feat_ticket_sold = int(a_t.db.adventure.feat_ticket_limit) + a_t._feat_ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + Kit.check("A6 限购满不再触发(保底冻结)", not a_t._feat_ticket_consider(rg_t)) + a_t._feat_ticket_sold = 0 + a_t._feat_ticket_pity = 0 + var offered := false + for i in 200: + if a_t._feat_ticket_consider(rg_t): + offered = true + break + Kit.check("A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)", offered) + a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}} + Kit.check("relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1], a_r.pick_curio("relic", "rare", "x3") and (a_r.feats_held() as Array).has("x3") and a_r.pool_stock("relic") == deck_relic - 1) + # 断点往返三条(基点 1420–1426:relics 快照/计数器键/余库逐枚还原)按主会话裁决②改判集成宿主—— + # 驱动代码(save_manager 装配+_on_level_finished+快照取读)随三条同去,集成批落位时重建。 + _db.feats = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知) + # ⑤ V3 订阅面逐件(附表 A 三列——命中面=EffectEngine dispatch 直测;账本面=counter 直读; + # 落地行为=发射点施加面〔T1 施加段承载——本段按声明逐件钉命中与边界/反例〕) + var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(73)) + var fx = orch._fx + # counter 落账 helper(dispatch 只返回命中不落账——施加面同构:counter_add 逐条落账本 helper 承载) + var apply_fx := func(hits: Array) -> void: + for h in hits: + if String(h.get("kind", "")) == "counter_add": + var sv_c = h.get("set", null) + EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if sv_c == null else int(sv_c), sv_c != null) + # 开格系 + fx.rebuild(["twin_pick"], [], []) + for i in 4: + var hits_dp: Array = fx.dispatch(orch.counters_ctx, "dig_paid", {"ap_cost": 1}) + var refund := false + for h in hits_dp: + if String(h.get("kind", "")) == "grant_ap": + refund = true + EffectEngine.counter_add(orch.counters_ctx, "adv_twin_digs", 1) + if i == 1 or i == 3: + Kit.check("双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" % (i + 1), refund) + else: + Kit.check("双管镐反例:第 %d 次不返(未满 2 次)" % (i + 1), not refund) + fx.rebuild(["geologist"], [], []) + var geo_hits: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": true}) + apply_fx.call(geo_hits) + Kit.check("地质学家:首开新类型集 1 点〔专长批 V3〕", orch._hits_of(geo_hits, "counter_add").size() == 1) + EffectEngine.counter_add(orch.counters_ctx, "geo_types", 2) # 手动基数补齐(row1 已落账 1+2=3——满 4 种的预评估现值) + var geo_hits2: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": true}) + apply_fx.call(geo_hits2) + Kit.check("地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕", orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.counters_ctx, "geo_types") == 0) + var geo_hits3: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": false}) + Kit.check("地质学家反例:同类型重复首开不叠点〔专长批 V3〕", orch._hits_of(geo_hits3, "counter_add").is_empty()) + fx.rebuild(["hard_hat"], [], []) + apply_fx.call(fx.dispatch(orch.counters_ctx, "dig_reveal", {})) + apply_fx.call(fx.dispatch(orch.counters_ctx, "dig_reveal", {})) + Kit.check("安全帽:开安全格攒层 ×2〔专长批 V3〕", EffectEngine.counter(orch.counters_ctx, "helm_layers") == 2) + var hh_hits: Array = fx.dispatch(orch.counters_ctx, "mine_hit", {"damage": 6}) + var hh_reduce := 0 + for h in hh_hits: + if String(h.get("kind", "")) == "damage_reduce": + hh_reduce = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", ""))) + Kit.check("安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕", hh_reduce == 2) + fx.rebuild(["skip_mining"], [], []) + var sk_zero: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": true}) + apply_fx.call(sk_zero) + Kit.check("跳格采掘:空白 0 格攒 1 点〔专长批 V3〕", orch._hits_of(sk_zero, "counter_add").size() == 1) + EffectEngine.counter_add(orch.counters_ctx, "skip_pts", 1) # 手动基数补齐(已落账 1+1=2——满 3 点的预评估现值) + var sk_full: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": true}) + apply_fx.call(sk_full) + Kit.check("跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕", orch._hits_of(sk_full, "reveal_tiles").size() == 1) + Kit.check("跳格采掘反例:普通开格不计点〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty()) + fx.rebuild(["blast_accounting"], [], []) + for i in 5: + var ba_hits: Array = fx.dispatch(orch.counters_ctx, "dig_paid", {"ap_cost": 1}) + for h in ba_hits: + if String(h.get("kind", "")) == "counter_add": + EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), 1) + Kit.check("爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1) + fx.rebuild(["frontier_miner"], [], []) + Kit.check("边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕", + orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1 + and orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty()) + # 封印系 + fx.rebuild(["seal_echo"], [], []) + apply_fx.call(fx.dispatch(orch.counters_ctx, "seal_hit", {})) + apply_fx.call(fx.dispatch(orch.counters_ctx, "seal_hit", {})) + var se_hits: Array = fx.dispatch(orch.counters_ctx, "round_end", {}) + Kit.check("封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕", orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue") + fx.rebuild(["seal_smith"], [], []) + var sm_remove := 0 + for i in 3: + var sm_dispatch: Array = fx.dispatch(orch.counters_ctx, "seal_hit", {}) + apply_fx.call(sm_dispatch) + for h in sm_dispatch: + if String(h.get("kind", "")) == "remove_mines": + sm_remove += 1 + Kit.check("封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕", sm_remove == 1) + fx.rebuild(["seal_stake"], [], []) + Kit.check("封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕", + orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1 + and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round") + fx.rebuild(["warning_beacon"], [], []) + Kit.check("示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "preview_area").size() == 1) + # 踩雷/受伤系 + fx.rebuild(["pain_memory"], [], []) + apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {})) + apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {})) + var pm_hits: Array = fx.dispatch(orch.counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}) + var pm_bonus := 0 + for h in orch._hits_of(pm_hits, "attack_bonus"): + pm_bonus = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", ""))) + Kit.check("疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕", pm_bonus == 2) + fx.rebuild(["fuse_wire"], [], []) + var fw_hits: Array = fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 6}) + var fw_shield := 0 + for h in fw_hits: + if String(h.get("kind", "")) == "shield": + fw_shield += int(h.get("value", 0)) + Kit.check("保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty()) + for h in fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 6}): + if String(h.get("kind", "")) == "counter_add": + EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 0))) + Kit.check("保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕", fw_shield == 0) + fx.rebuild(["blood_insight"], [], []) + Kit.check("见血开窍:踩雷揭示 1 格〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1) + fx.rebuild(["trap_instinct"], [], []) + apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {})) + var ti_hits: Array = fx.dispatch(orch.counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}) + var ti_val := 0 + for h in orch._hits_of(ti_hits, "attack_bonus"): + ti_val = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", ""))) + Kit.check("陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕", ti_val == 2) + fx.rebuild(["chain_traps", "pain_memory"], [], []) + apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {})) + var ct_before: int = EffectEngine.counter(orch.counters_ctx, "pain_layers") + apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {})) + Kit.check("连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕", EffectEngine.counter(orch.counters_ctx, "pain_layers") == ct_before + 2) + fx.rebuild(["trap_burst"], [], []) + var tb_hits: Array = fx.dispatch(orch.counters_ctx, "mine_hit", {"damage": 8}) + Kit.check("陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕", + orch._hits_of(tb_hits, "blast_scaled").size() == 1 + and float((orch._hits_of(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0) + fx.rebuild(["blood_honey"], [], []) + Kit.check("血蜜:踩雷回复 N 生命〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "heal").size() == 1) + fx.rebuild(["stone_skin"], [], []) + var ss_hits: Array = fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1}) + Kit.check("石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕", + orch._hits_of(ss_hits, "ac_bonus").size() == 1 + and int((orch._hits_of(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3) + fx.rebuild(["last_stand"], [], []) + Kit.check("背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕", + orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1 + and orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty()) + # 跨族桥 + fx.rebuild(["breaker_strike"], [], []) + Kit.check("破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕", + orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 + and orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty()) + # 节奏系 + fx.rebuild(["map_exchange"], [], []) + var me_hits: Array = fx.dispatch(orch.counters_ctx, "dig_stop", {"ap_left": 4}) + apply_fx.call(me_hits) + EffectEngine.counter_add(orch.counters_ctx, "stop_swap", 4) # value_event="ap_left" 解析归施加面(action_economy 载荷取值)——冒烟手动落账 + Kit.check("弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕", orch._hits_of(me_hits, "counter_add").size() == 1) + var me_atk := 0 + for h in orch._hits_of(fx.dispatch(orch.counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}), "attack_bonus"): + me_atk = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", ""))) + Kit.check("弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕", me_atk == 4) + Kit.check("弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty()) + fx.rebuild(["last_drop"], [], []) + var ld_hits: Array = fx.dispatch(orch.counters_ctx, "round_start", {"round": 2, "ap_exhausted_last": true}) + Kit.check("一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕", orch._hits_of(ld_hits, "grant_ap").size() == 1) + Kit.check("一滴不剩反例:上轮有余不发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty()) + fx.rebuild(["market_close"], [], []) + for i in 8: + for h in fx.dispatch(orch.counters_ctx, "dig_reveal", {}): + if String(h.get("kind", "")) == "counter_add": + EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), 1) + Kit.check("收盘:本轮开格≥8→+1 GP〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1) + # 战斗系 + fx.rebuild(["execution_scholar"], [], []) + Kit.check("斩首学者:击杀揭示 1 格〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1) + fx.rebuild(["executioner"], [], []) + var ex_hits: Array = fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 30, "is_boss": false}) + Kit.check("处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕", + orch._hits_of(ex_hits, "target_attack_bonus").size() == 1 + and orch._hits_of(fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty()) + fx.rebuild(["field_dressing"], [], []) + Kit.check("战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1) + # 契约尖点 + fx.rebuild(["blood_tax"], [], []) + Kit.check("血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕", + orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "bonus_delta").size() == 1 + and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk") + # 迁入 2 件 + fx.rebuild(["venom_edge"], [], []) + Kit.check("淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕", + orch._hits_of(fx.dispatch(orch.counters_ctx, "clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1 + and orch._hits_of(fx.dispatch(orch.counters_ctx, "clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty()) + fx.rebuild(["seal_burst"], [], []) + Kit.check("封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "open_ticket").size() == 1) + # 零持有 + var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74)) + orch0._fx.rebuild([], [], []) + Kit.check("零持有订阅=零命中(relic 关着零扰动——伤害管线既有行为逐位不变)", + orch0._fx_emit("attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}).is_empty() + and orch0._fx_emit("dig_reveal", {"cell": Vector2i(1, 1), "round": 1, "is_first_dig_of_battle": true, "terrain_kind": []}).is_empty() + and orch0._fx_emit("mine_hit", {"damage": 5}).is_empty() + and orch0._fx_emit("battle_won", {"round": 1}).is_empty()) + # ⑦c 特性持有呈现段(C 区「特性」节;呈现面在 06 在册) + var panel = load("res://presentation/views/hero_info_panel_view.gd").new() + panel.set_feat_holdings([]) + Kit.check("特性段空态隐藏(视图空态纪律同特性段)", not panel._feat_section.visible) + panel.set_feat_holdings(["疼痛记忆 ×1", "双管镐 ×1"]) + Kit.check("特性段持有呈现(逐件行常驻可读——「局内奖励选了什么看不到」缺口承载)", panel._feat_section.visible and panel._feat_list.get_child_count() == 2) + Kit.check("特性卡文案表值化:desc 行字段直读(零硬编码映射)", String(load("res://presentation/battle_narrator.gd").feat_card_text({"feat_id": "pain_memory", "display_name": "疼痛记忆", "desc": "触陷阱攒 1 层"})).contains("触陷阱攒 1 层")) + + +# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排) + + +# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排) +func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: + var guard := 24 + while guard > 0 and String(st["result"]) == "": + guard -= 1 + autoplay._drive_level(a.runner(), st, null, "normal", "min") + if String(st["result"]) != "": + return false + if a._pending_next_level: + # 三选一停点确认(确定性选件后推进,与 run_adventure 自动策略同口径;TP0 令牌化) + if not a._curio_pending.is_empty(): + var bcands: Array = a._curio_pending["candidates"] + var bpick: Dictionary = autoplay._pick_token(bcands) + a.pick_curio(String(bpick.get("kind", "")), String(bpick.get("tier", "")), String(bpick.get("feat_id", ""))) + a.continue_to_next_level() + continue + var r = a.runner() + if String(r.current_node().get("type", "")) == "shop": + return true + if r.awaiting_choice(): + var ids: Array = r.next_candidate_ids() + ids.sort() + r.pick_next_node(int(ids[0])) + continue + var t := String(r.current_node().get("type", "")) + if t == "reward": + # 奖励节点三选一(确定性选件、替换放弃新件、兑付确认回图;TP0 令牌化) + var ndraw: Dictionary = a.node_curio_candidates() + var ncands: Array = ndraw.get("candidates", []) as Array + if not ncands.is_empty(): + a.present_node_curio(ncands) + var npick: Dictionary = autoplay._pick_token(ncands) + a.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("feat_id", ""))) + r.continue_after_node() + elif t == "rest": + r.continue_after_node() + return false + + +# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集) + + +# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集) +func _adv_st() -> Dictionary: + return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0, + "rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "", + "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "", + "policy": "normal", "refreshes": 0, "mine_hits": 0, "visited_ids": [], "choices": 0} + + + +# ---- 未开格类型微光·数据侧断言(V1 作用集恒等/V2 九型归组封闭/V7 零玩法写入)---- +# 夹具纪律:身份回调形状与 reward_economy.query_identity 同构({reward_id, cell_type, tier}); +# 位点覆盖表 _glow_type_overrides(Vector2i → cell_type)+封锁集 _glow_blocked(Vector2i → true, +# 返回 null = 不在摇号清单的封锁格)。哈希兜底按坐标确定性散列——全缸可复现。 + + +# 手搓战斗状态袋单点(7 处字面量收在一处;_autoplay_battle 直接消费的最小字段族。 +# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面) +func _battle_st(policy: String) -> Dictionary: + return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy} + + +# ==================== 敌人技能池(开轮摇号出招系统) ==================== +# 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 / +# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。 + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# ==================== GP+商店房冒烟 ==================== +# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 + +# ---- ① GP 冒险钱包·到账 ---- + + +# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) +func _level_active_nodes(level_index: int) -> Array: + for row in _db.level.rows: + if int(row.level_index) == level_index: + var tpls: Array = row.get("templates", []) + if not tpls.is_empty(): + return tpls[0].get("nodes", []) + return row.get("nodes", []) + return [] diff --git a/game/tests/unit/core/board_gen_reward_test.gd b/game/tests/unit/core/board_gen_reward_test.gd new file mode 100644 index 00000000..ea75b84f --- /dev/null +++ b/game/tests/unit/core/board_gen_reward_test.gd @@ -0,0 +1,290 @@ +# tests/unit/core/board_gen_reward_test.gd — 棋盘生成与地图战斗资源单元测试宿主 +# 断言面 = 四套(批 B 断言迁移自 smoke_all.gd,G3 施工组): +# 棋盘布局格边长公式(board_view 格尺寸——下缘预留回归,真人试玩 bug⑥) +# 地图战斗资源·身份面(roll 恰一次 / 五型封闭 / 幂等 / 清图批尾 / 退役闭合) +# 棋盘生成器(行解析 / 可清性 / 合法性 / 出图验收 / 三分出口 / 确定性 / r0 闸) +# 地图战斗资源·伤害量值区间(中点锚 / 域内 / 流动 / 定值不受扰) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB/RngService 显式实例化构造注入(统一流 master 种子 42 沿源); +# 表加载失败不中止(沿被迁断言面的源语义——诊断在此展开,表值变异的红由具体断言落,承 G2 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/core/board_gen_reward_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _db +var _rng + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[board_gen_reward_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + _rng = load("res://core/rng_service.gd").new() + _rng.set_master_seed(42) + # 套执行序沿源编排序 + _smoke_layout_cell_formula() + _smoke_reward_identity_unit() + _smoke_board_generator() + _smoke_power_interval() + quit(Kit.finish("board_gen_reward_test")) + + +# ---- 隔离夹具 ---- + +# 定域流桩(scope 派生种子——derive_at 纯函数,与取号计数器状态无关) +func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator: + # stream_name = 调用点自述标签(不参与派生),保留参数零改调用方 + var rg := RandomNumberGenerator.new() + rg.seed = _rng.derive_at(scope) + return rg + + +# ---- 战斗屏棋盘布局·格边长公式回归(真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高—— +# 公式 N×cell ≤ 可用高−12 必须计入 (N−1)×2px 网格间距,否则网格实高溢出被 clip_contents 裁掉; +# 现公式 = 计入间距 + 垂直预留 24px〔上 12/下 12——底部不贴 D 区边框〕)---- +func _smoke_layout_cell_formula() -> void: + print("--- 棋盘布局格边长公式(底部预留回归)---") + var board_generator = load("res://core/board_generator.gd").new() + var board_view = load("res://presentation/views/board_view.gd").new() + for L in [1, 2]: + var b = board_generator.generate(L, 0, _mk_stream("board_gen", 555), _db) + board_view.rebuild(b, null) + var avail := Vector2(1036, 460) # 受约束 D 区可用面积样本(1280×800 档量级——用户实测裁切场景) + board_view.set_cell_area(avail) + var n: int = maxi(b.width, b.height) + var cell: float = board_view.get("_cell") + var grid_h := n * int(cell) + (n - 1) * 2 + Kit.check("L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" % [L, b.width, b.height, grid_h, int(avail.y), int(cell)], + grid_h <= int(avail.y) - 24) + Kit.check("L%d 格边在 26–46 档内(%dpx)" % [L, int(cell)], + cell >= 26.0 and cell <= 46.0) + board_view.free() + + +# ---- 地图战斗资源·身份面单元冒烟(格面资源:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)---- + +func _smoke_reward_identity_unit() -> void: + print("--- 地图战斗资源·格面资源身份面 ---") + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260911) + var reward_economy_factory = load("res://core/reward_economy.gd") + var reward_economy = reward_economy_factory.new(_db, rng) + var eligible: Array = [] + for i in range(6): + eligible.append({"cell": Vector2i(i, 0)}) + Kit.check("roll_board 受理", reward_economy.roll_board(eligible)) + Kit.check("重复 roll_board 被拒(恰一次——double-roll 守卫)", not reward_economy.roll_board(eligible)) + # 身份查询投影 = {reward_id, cell_type, tier};五型封闭 + var types_ok := true + var types_seen := {} + for e in eligible: + var ident: Dictionary = reward_economy.query_identity(e.cell) + if not (ident.has("reward_id") and ident.has("cell_type") and ident.has("tier")): + types_ok = false + if not RewardTable.CELL_TYPES.has(String(ident.cell_type)): + types_ok = false + types_seen[String(ident.cell_type)] = true + Kit.check("query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" % str(types_seen.keys()), types_ok) + # 清单外坐标 = null(E19——封锁格不入清单的同款出口) + Kit.check("清单外坐标查询 = null(封锁格无身份同口径)", reward_economy.query_identity(Vector2i(99, 99)) == null) + # 同格重复受理幂等:同一快照逐字段相同 + var snap1: Dictionary = reward_economy.grant(eligible[0].cell) + var snap2: Dictionary = reward_economy.grant(eligible[0].cell) + Kit.check("同格重复 grant 幂等(同一快照——受理恰一次)", JSON.stringify(snap1) == JSON.stringify(snap2)) + Kit.check("清单外 grant 拒绝", reward_economy.grant(Vector2i(99, 99)) == null) + # 清图批尾身份:clear_default 唯一活行 clear_blast(blast 型正向);重调幂等 + var cs1: Dictionary = reward_economy.grant_clear() + var cs2: Dictionary = reward_economy.grant_clear() + Kit.check("清图批尾身份 = clear_blast(clear_default 唯一活行)", String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast") + var cb_range := [-1, -1] + for row in _db.reward.rows: + if String(row.reward_id) == "clear_blast": + cb_range = [int(row.get("power_min", row.power)), int(row.get("power_max", row.power))] + Kit.check("清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)], + int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1])) + Kit.check("grant_clear 同批次重调幂等", JSON.stringify(cs1) == JSON.stringify(cs2)) + # 退役后受理拒绝 + reward_economy.retire_mapping() + Kit.check("映射退役后 grant 拒绝(生命周期闭合)", reward_economy.grant(eligible[0].cell) == null) + + +# ---- 棋盘生成(生成-验证循环 / 三分出口 / r0 闸)---- + +# 板行覆盖桩(cap 压线行为断言;接口面 = resolve_board_gen 单点) +class _BoardRowStub: + var row: Dictionary + func resolve_board_gen(_L: int, _D: int) -> Dictionary: + return row + func status_effect_row(_id: String) -> Dictionary: + return {} + + +func _smoke_board_generator() -> void: + print("--- 棋盘生成 ---") + var board_generator = load("res://core/board_generator.gd").new() + for L in [1, 2]: + var row = _db.resolve_board_gen(L, 0) + Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty()) + if L == 2: + # R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4 + var ceil_row: Dictionary = _db.resolve_board_gen(2, 3) + Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4) + var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db) + Kit.check("L%d 生成产物非空" % L, b1 != null) + if b1 == null: + continue + Kit.check("L%d clearable(安全格 8 邻连通)" % L, board_generator.is_clearable(b1)) + Kit.check("L%d legality 全过" % L, board_generator.legality(b1, row, _db)) + var inf := float(b1.metrics.get("inferability", 0.0)) + var mode := String(b1.metrics.get("accept_mode", "")) + print(" L%d 实测:inferability=%.3f / attempts_used=%d / accept_mode=%s / target=%.2f" % [ + L, inf, int(b1.metrics.get("attempts_used", -1)), mode, float(row.inferability_target)]) + # 出图验收(棋盘生成 登记口径):first_pass 必须 ≥ target;accept_best(重试耗尽接受最优)允许低于 target + # 但必须打印实测值(正常出口);accept_worst 由下方 illegal_product 断言承担。 + Kit.check("L%d inferability %.3f %s target %.2f(accept_mode=%s)" % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode], + inf >= float(row.inferability_target) or mode == "accept_best") + Kit.check("L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" % L, b1.variants.is_empty()) + Kit.check("L%d 指标集输出降级标记与目标雷数(三分出口表)" % L, b1.metrics.has("degraded_product") and b1.metrics.has("target_mines")) + Kit.check("L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" % [L, mode, str(b1.degraded_product)], + b1.degraded_product == (mode == "accept_best")) + Kit.check("L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)], + b1.illegal_product == (mode == "accept_worst")) + Kit.check("L%d 降级/非法产物标记互斥(两出口不可同真)" % L, + not b1.degraded_product or not b1.illegal_product) + Kit.check("L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)], + int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines)) + Kit.check("L%d attempts_used ∈ [1,%d]" % [L, int(row.max_gen_attempts)], + b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts)) + Kit.check("L%d 非法产物标记 = false" % L, not b1.illegal_product) + # 生成确定性:同种子逐格相同 + var b2 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db) + var b3 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db) + Kit.check("L%d 同种子两次生成逐格相同" % L, _board_dump(b2) == _board_dump(b3)) + var b4 = board_generator.generate(L, 0, _mk_stream("board_gen", 778), _db) + Kit.check("L%d 异种子产物不同(退化护栏)" % L, _board_dump(b2) != _board_dump(b4)) + # ---- 初始展开收窄断言 ---- + # ① 结构断言:R₀ ≤ cap(正式表行投值,合法性闸承载——出图必过);② r0_cap_rejected 尝试事实在位; + # ③ ring_removed 量级:≤ 域内候选危险格数(5×5−出生 9=16——无全域剃雷); + # ④ 闸压线行为:cap=9(出生保障面下限)时出图必 r0≤9 或 accept_worst 非法标记(三分出口诚实降级,玩家永不卡死) + var cap_val: int = int(row.get("r0_max_cap", 0)) + Kit.check("L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" % [L, cap_val], + cap_val >= BoardData.SPAWN_AREA_CELLS) + Kit.check("L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" % [L, int(b1.metrics.r0_count), cap_val], + int(b1.metrics.r0_count) <= cap_val) + Kit.check("L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" % [L, int(b1.metrics.get("r0_cap_rejected", -1))], + int(b1.metrics.get("r0_cap_rejected", -1)) >= 0) + Kit.check("L%d ring_removed=%d ≤ 剃雷域候选危险格数((3+2r)²−9——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))], + int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5) + var row9: Dictionary = row.duplicate() + row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS + row9["max_gen_attempts"] = 4 + var stub9 := _BoardRowStub.new() + stub9.row = row9 + var b9 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), stub9) + Kit.check("L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"], + b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product)) + Kit.check("L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))], + int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "") + + +# 排雷 core 板面摘要(确定性对照面——尺寸/出生点/逐格雷位与变体清单) +func _board_dump(b) -> String: + var parts: Array = [str(b.width), str(b.height), str(b.spawn_center)] + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + parts.append("%d:%d:%d:%s" % [x, y, 1 if b.is_mine(c) else 0, str(b.variants.get(c, []))]) + return "\n".join(parts) + + +# ---- 地图战斗资源·伤害量值区间冒烟(roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)---- + +func _smoke_power_interval() -> void: + print("--- 地图战斗资源·伤害量值区间(上阶/下阶随机)---") + # ① 表侧静态锚(配置推导——改表不断锚):凡带 power_min/power_max 的行 = 区间行, + # 校验配对齐全(lo ≤ hi)+ power = floor 中点(行动经济 条款①期望当量锚);其余行必须零区间字段。 + # 行清单与区间值全部随表走——现行区间行 heal_minor/clear_blast 只是恰然,不再是锚。 + var interval_rows := {} # rid → [lo, hi](读表) + var anchors_ok := true + for row in _db.reward.rows: + var rid := String(row.reward_id) + if row.has("power_min") or row.has("power_max"): + var lo: int = int(row.get("power_min", -1)) + var hi: int = int(row.get("power_max", -1)) + interval_rows[rid] = [lo, hi] + if lo > hi or int(row.power) != (lo + hi) / 2: + anchors_ok = false + Kit.check("区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" % [interval_rows.size(), str(interval_rows.keys())], + anchors_ok and interval_rows.size() >= 1) + # ② 运行侧:24 板 roll——区间行快照 power 全域内 + 上下阶实际流动 + 定值行不受扰 + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20260914) + var reward_economy_factory = load("res://core/reward_economy.gd") + var eligible: Array = [] + for i in range(6): + eligible.append({"cell": Vector2i(i, 0)}) + var seen := {} + var draws := {} # rid → 总抽数(流动判据用——seen 只记不同值) + for rid in interval_rows.keys(): + seen[rid] = {} # rid → {roll 值集}(区间行随表注册) + draws[rid] = 0 + var in_domain := true + var fixed_ok := true + for trial in range(24): + var reward_economy = reward_economy_factory.new(_db, rng) + if not reward_economy.roll_board(eligible): + continue + for e in eligible: + var snap: Dictionary = reward_economy.query_full_snapshot(e.cell) + var rid := String(snap.reward_id) + if interval_rows.has(rid): + var v := int(snap.power) + seen[rid][v] = true + draws[rid] = int(draws.get(rid, 0)) + 1 + if v < interval_rows[rid][0] or v > interval_rows[rid][1]: + in_domain = false + elif int(snap.power) != _row_power(rid): + fixed_ok = false # 定值行快照 power 必须仍是表值 + var cs: Dictionary = reward_economy.grant_clear() + var cv := int(cs.power) + var c_rid := String(cs.reward_id) + if not seen.has(c_rid): + seen[c_rid] = {} + if not draws.has(c_rid): + draws[c_rid] = 0 + seen[c_rid][cv] = true + draws[c_rid] = int(draws.get(c_rid, 0)) + 1 + if interval_rows.has(c_rid) and (cv < interval_rows[c_rid][0] or cv > interval_rows[c_rid][1]): + in_domain = false + Kit.check("24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)", in_domain) + Kit.check("定值行快照 power 恒等表值——区间批零外溢", fixed_ok) + # 定值行(区间 lo==hi 的行,如 heal_minor)roll 恒等表值;区间行(lo 0 and not (samples == 1 and seen[rid].has(lo)): + fixed_rows_ok = false + elif int(draws.get(rid, 0)) >= 2 and seen[rid].size() < 2: + variance_ok = false + Kit.check("区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)", variance_ok) + Kit.check("定值行(lo=hi)roll 恒等表值", fixed_rows_ok) + + +func _row_power(rid: String) -> int: + for row in _db.reward.rows: + if String(row.reward_id) == rid: + return int(row.power) + return -1 diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd new file mode 100644 index 00000000..0800bd1e --- /dev/null +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -0,0 +1,1017 @@ +# tests/unit/core/level_shop_economy_test.gd — 关卡图与 GP 商店单元测试宿主 +# 断言面 = 七套(批 B 断言迁移自 smoke_all.gd,G8 施工组): +# 关卡节点地图·线性推进(满配通关/恢复点公式/败北线/回放确定性/属性覆盖恢复) +# 分支节点图(图校验正反/L2·L3 行进与行集定点/混编运行面/真图类型可见性) +# GP 冒险钱包·到账(掷定窗口/关级加值) +# escrow 隔离(格面兑付入缓冲/破财直验/钱包钳制不透支) +# 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零) +# 商店房图不变式(恰 1 商店房/全路径必经/跨表行集迁移) +# 商店共享池(抽 3 买 1/乘法定价/跨渠道共库/断点扩键/终局池读数) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(_fresh_rng 同种子隔离夹具——RngService 构造注入 runner); +# 表加载失败不中止(沿被迁断言面的源语义——承 G2–G6 同款)。 +# 运行:godot --headless --path game --script res://tests/unit/core/level_shop_economy_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[level_shop_economy_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排(基点 _run_core_smoke 调用序——本组套的相对序) + _smoke_level() + _smoke_level_graph() + _smoke_gp_wallet() + _smoke_gp_escrow() + _smoke_shop_room() + _smoke_shop_graph() + _smoke_shop_pool() + quit(Kit.finish("level_shop_economy_test")) + + +# ---- 关卡节点地图·线性推进冒烟---- + +func _smoke_level() -> void: + print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---") + var autoplay = load("res://smoke/level_autoplay.gd") + var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) + # ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖) + var win = autoplay.run_level(_db, 42, 1, 200, 100) + Kit.check("关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index], win.result == "victory") + Kit.check("战斗节点数 = 5(战×4 + Boss)", win.battles == 5) + Kit.check("Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)", win.boss_depth == 4) + Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3) + # ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2 + # seed 42(探针目的 = 验回复公式,种子仅载体) + var rest = autoplay.run_level(_db, 42, 1, 100, 100) + if rest.result != "start_failed" and rest.hp_before_rest >= 0: + var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线) + var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest) + Kit.check("恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max], rest.rest_healed == expect) + else: + Kit.check("恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result, false) + # ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关) + var lose = autoplay.run_level(_db, 42, 1, -80, -80) + Kit.check("关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result, lose.result == "defeat") + Kit.check("败北节点记录在域内(节点 %d)" % lose.defeat_node_index, lose.defeat_node_index >= 0 and lose.defeat_node_index < 7) + # ④ 回放确定性:同种子两遍步骤流逐行相同 + var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) + var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) + Kit.check("关卡回放断言:同种子两遍结算步骤流逐行相同", JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows)) + Kit.check("关卡步骤流非空(%d 行)" % rep1.rows.size(), rep1.rows.size() > 0) + # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随) + Kit.check("模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)], + int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \ + and int(_db.hero.armor_class) == int(hero_base.ac)) + + +# 手搓战斗状态袋单点(7 处字面量收在一处;_autoplay_battle 直接消费的最小字段族。 +# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面) +func _battle_st(policy: String) -> Dictionary: + return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy} + + +# ==================== 敌人技能池(开轮摇号出招系统) ==================== +# 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 / +# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。 + + +# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域图版行进(关卡节点地图 v1.0 图版)---- + +func _smoke_level_graph() -> void: + print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") + var autoplay = load("res://smoke/level_autoplay.gd") + # ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述) + Kit.check("LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误", _db.level.validate().is_empty()) + # ② L2 行集结构(网状图建议规模) + var l2 := {} + for row in _db.level.rows: + if int(row.level_index) == 2: + l2 = row + Kit.check("L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)", not l2.is_empty() and _level_active_nodes(2).size() == 10) + var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} + var branch := 0 + for node in _level_active_nodes(2): + tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1 + if node.get("next", []).size() >= 2: + branch += 1 + Kit.check("L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount.battle) == 6 and int(tcount.boss) == 1) + Kit.check("L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1) + Kit.check("L2 真实岔路 = %d 处(≥2)" % branch, branch >= 2) + # ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法) + var lt = load("res://data/schemas/level_table.gd").new() + var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]}, + {"node_depth": 2, "type": "boss"}] + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}]) + Kit.check("图校验正例:两岔合流小图零错误", lt.validate().is_empty()) + var dup = JSON.parse_string(JSON.stringify(good)) + dup[1].node_depth = 0 + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}]) + Kit.check("图校验拒绝:节点编号重复(关卡内唯一定位地址)", _has_err_category(lt.validate(), "dup_key")) + var dual = JSON.parse_string(JSON.stringify(good)) + dual.append({"node_depth": 9, "type": "battle", "next": [2]}) + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}]) + Kit.check("图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)", _has_err_category(lt.validate(), "structural")) + var boss_out = JSON.parse_string(JSON.stringify(good)) + boss_out[2].next = [1] + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}]) + Kit.check("图校验拒绝:Boss 带出边(终点不可外出)", _has_err_category(lt.validate(), "structural")) + var cyc = JSON.parse_string(JSON.stringify(good)) + cyc[1].next = [0] + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}]) + Kit.check("图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)", _has_err_category(lt.validate(), "structural")) + var dang = JSON.parse_string(JSON.stringify(good)) + dang[0].next = [1, 7] + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}]) + Kit.check("图校验拒绝:出边指向不存在编号(悬空致命)", _has_err_category(lt.validate(), "dangling_next")) + var orphan = JSON.parse_string(JSON.stringify(good)) + orphan[0].next = [1] + orphan[1].next = [2] + orphan.append({"node_depth": 5, "type": "battle", "next": [2]}) + lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}]) + var orphan_errs: Array = lt.validate() + Kit.check("图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)", _has_err_category(orphan_errs, "structural")) + # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) + # 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体) + var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) + Kit.check("L2·满配 min 路径通关(实测:%s)" % win_min.result, win_min.result == "victory") + Kit.check("L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)", win_min.boss_depth == 9) + Kit.check("min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids), win_min.visited_ids == [0, 1, 3, 7, 8, 9]) + Kit.check("选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices, win_min.choices >= 3) + # max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表 + # 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300) + # 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + + # 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次 + # 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。 + const MAX_PATH_SEED := 43 # 种子重采程序:同款循环首命中(断言注释既定程序,临时脚本用后即删) + var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0) + Kit.check("L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED, win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9]) + # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言) + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + runner.set_hero_override(200, 100) + var choice_sizes: Array = [] + runner.choices_presented.connect(func(c): choice_sizes.append(c.size())) + Kit.check("手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)", runner.start_level(2, 42, {}, 0)) + var st = _battle_st("normal") + var first = runner.current_battle() + var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变) + first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", ""))) + while runner.current_battle() == first and not res_box.has("result"): + autoplay._autoplay_battle(runner, first, st, null) + Kit.check("首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)", String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice()) + Kit.check("入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)", runner.next_candidate_ids() == [1, 2]) + Kit.check("候选外编号拒绝且仍待选(拒绝语义)", not runner.pick_next_node(8) and runner.awaiting_choice()) + Kit.check("选编号 2(奖励点)受理", runner.pick_next_node(2)) + Kit.check("到达奖励点:直通兑付(确认前不出选路口)", String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice()) + Kit.check("兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]", runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6]) + Kit.check("选编号 6(巨蜘蛛巢群)开战", runner.pick_next_node(6) and runner.current_battle() != null) + # ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描 + # (绿档口径 = 12 局多数胜 + 零踩雷恒要求;还原全胜档需回调锚行血量,归用户决策) + var green_ok := true + var green_min_hp := 99999 + var green_wins := 0 + for i in range(12): + var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) + g_runner.start_level(2, 9100 + i) + var g_st = _battle_st("perfect") + var g_first = g_runner.current_battle() + var g_box := {} + g_first.battle_finished.connect(func(p): + g_box["result"] = String(p.get("result", "")) + g_box["hero_hp"] = int(p.get("hero_hp", 0))) + var g_guard := 6 + while g_runner.current_battle() == g_first and not g_box.has("result") and g_guard > 0: + g_guard -= 1 + autoplay._autoplay_battle(g_runner, g_first, g_st, null) + if int(g_st.mine_hits) > 0: + green_ok = false + if String(g_box.get("result", "")) == "victory": + green_wins += 1 + if int(g_box.get("hero_hp", 0)) > 0: + green_min_hp = mini(green_min_hp, int(g_box.hero_hp)) + Kit.check("首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷", green_ok) + Kit.check("首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins, green_wins >= 7) + Kit.check("首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp, green_min_hp > 0) + # ⑦ 跨表行完整:L2 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(加载校验同款显式复述; + # enemy 键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域) + var bg_keys := {} + for row in _db.board_gen.rows: + bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var en_keys := {} + for row in _db.enemy.rows: + en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true + var cross_ok := true + for node in _level_active_nodes(2): + var t := String(node.get("type", "")) + if t != "battle" and t != "boss": + continue + if not bg_keys.has("2_%d" % int(node.node_depth)) or not en_keys.has("2_%s_%d" % [t, int(node.node_depth)]): + cross_ok = false + Kit.check("跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (2,row_kind,depth_band))", cross_ok) + # ⑦a 定点:L2 首遇带行集与 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; + # 断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) + var l2_spider_band: int = _first_encounter_band(2, "giant_spider") + var l2_spider_set: Array = _db.resolve_enemy_rowset(2, l2_spider_band, "battle") + var l2_spider_ids := {} + for r in l2_spider_set: + l2_spider_ids[String(r.get("enemy_id", ""))] = true + Kit.check("巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" % l2_spider_band, + l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \ + and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max")) + var l2_boss_set: Array = _db.resolve_enemy_rowset(2, _boss_depth(2), "boss") + var l2_boss_ids := {} + for r in l2_boss_set: + l2_boss_ids[String(r.get("enemy_id", ""))] = true + Kit.check("蛛化卓尔精灵 Boss 行集(2,boss,%d)= {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % _boss_depth(2), l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward") + # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: + # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) + var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") + var mix_shape_ok: bool = l2_mix_set.size() == 2 \ + and String(l2_mix_set[0].get("enemy_id", "")) == "bugbear" \ + and String(l2_mix_set[1].get("enemy_id", "")) == "giant_spider" + Kit.check("首批混编行集 (2,battle,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)〔fv18〕", mix_shape_ok) + var mix_sum_max := 0 + for r in l2_mix_set: + mix_sum_max += int(r.get("unit_count_max", int(r.get("unit_count", 1)))) + Kit.check("首批混编行集 (2,battle,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])〔fv19〕" % mix_sum_max, mix_sum_max <= 4) + Kit.check("首批混编新行 (2,battle,5,giant_spider) 行值 = 复制巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)", + int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \ + and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2) + # ---- L3 灰矮人矿城断言段 ---- + # ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处) + var l3 := {} + for row in _db.level.rows: + if int(row.level_index) == 3: + l3 = row + Kit.check("L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)", not l3.is_empty() and _level_active_nodes(3).size() == 10) + var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} + var branch3 := 0 + for node in _level_active_nodes(3): + tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1 + if node.get("next", []).size() >= 2: + branch3 += 1 + Kit.check("L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount3.battle) == 6 and int(tcount3.boss) == 1) + Kit.check("L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1) + Kit.check("L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3, branch3 == 3) + # ⑨ 跨表行完整:L3 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用—— + # 键域同形:(L,row_kind,depth_band)) + var cross_ok3 := true + for node in _level_active_nodes(3): + var t3 := String(node.get("type", "")) + if t3 != "battle" and t3 != "boss": + continue + if not bg_keys.has("3_%d" % int(node.node_depth)) or not en_keys.has("3_%s_%d" % [t3, int(node.node_depth)]): + cross_ok3 = false + Kit.check("跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (3,row_kind,depth_band))", cross_ok3) + # ⑩ 定点:L3 首遇带行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) + var l3_lead_band: int = _first_encounter_band(3, "duergar") + var l3_lead_set: Array = _db.resolve_enemy_rowset(3, l3_lead_band, "battle") + var l3_lead_ids := {} + for r in l3_lead_set: + l3_lead_ids[String(r.get("enemy_id", ""))] = true + Kit.check("灰矮人首遇带(3,battle,%d——扫表取最低带)行集 = {duergar} 恰一行、首行重甲、固定 1 只不加骰〔fv23〕" % l3_lead_band, + l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \ + and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \ + and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max")) + var l3_king_set: Array = _db.resolve_enemy_rowset(3, _boss_depth(3), "boss") + var l3_king_ids := {} + for r in l3_king_set: + l3_king_ids[String(r.get("enemy_id", ""))] = true + Kit.check("灰矮人石君王 Boss 行集(3,boss,%d)= {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % _boss_depth(3), l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy") + # ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过 + # (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥) + var green3_ok := true + var green3_min_hp := 99999 + for i in range(12): + var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) + g3_runner.set_hero_override(200, 100) + g3_runner.start_level(3, 9100 + i) + var g3_st = _battle_st("perfect") + var g3_first = g3_runner.current_battle() + var g3_box := {} + g3_first.battle_finished.connect(func(p): + g3_box["result"] = String(p.get("result", "")) + g3_box["hero_hp"] = int(p.get("hero_hp", 0))) + var g3_guard := 6 + while g3_runner.current_battle() == g3_first and not g3_box.has("result") and g3_guard > 0: + g3_guard -= 1 + autoplay._autoplay_battle(g3_runner, g3_first, g3_st, null) + if String(g3_box.get("result", "")) != "victory" or int(g3_st.mine_hits) > 0: + green3_ok = false + elif int(g3_box.get("hero_hp", 0)) > 0: + green3_min_hp = mini(green3_min_hp, int(g3_box.hero_hp)) + Kit.check("L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷", green3_ok) + Kit.check("L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green3_min_hp, green3_min_hp > 0) + + # ⑫ 混编行集运行面正反断言:注入带键 (1,battle,5)/(1,battle,6) = + # L1 节点图与全部既有扫描面之外的新带键(带命中精确取注入行集——新 L 会被 ceil 封顶到 L3 不可用), + # 断言后立即还原表,后续断言面零污染。R3 fail-fast 正反验证 + R6 行序运行面验证。 + var mix_row_a := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "goblin", "display_name": "地精", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} + var mix_row_b := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 2, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]} + _db.enemy.rows.append(mix_row_a) + _db.enemy.rows.append(mix_row_b) + var mix_sys = load("res://core/unit_container.gd").new(_db) + Kit.check("混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)", mix_sys.spawn(1, 5, int(_db.hero.max_hp))) + var mix_units: Array = mix_sys.all_enemies() + Kit.check("混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)", mix_units.size() == 3) + Kit.check("混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)", String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear") + Kit.check("混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)", String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3) + _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) + _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) + var over_sys = load("res://core/unit_container.gd").new(_db) + Kit.check("行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)", not over_sys.spawn(1, 6, int(_db.hero.max_hp))) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + Kit.check("混编断言段注入行全量还原(表回 20 行——首批混编投放后基线行数,迭代 18 卡⑤;后续断言面零污染)", int(_db.enemy.rows.size()) == 20) + + # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / + # Boss 恒可见 / 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 + var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + var vis_ledger := {"hero_hp": 10, "gp": 0} + var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true, 0) + Kit.check("可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)", vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1) + var vis_ids: Array = vis_runner.type_visible_ids() + var vis_cand_in := true + for cid in vis_runner.next_candidate_ids(): + if not vis_ids.has(int(cid)): + vis_cand_in = false + Kit.check("可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕", vis_cand_in) + Kit.check("可见性冒烟:L2 Boss(编号 %d)恒在可见集(终点不入雾)〔fv3〕" % _boss_depth(2), vis_ids.has(_boss_depth(2))) + var vis_n: int = int(_db.level.info_visible_layers) + Kit.check("可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n, vis_n == 2) + Kit.check("可见性冒烟:入口锚层 0 时层距 3/4 的节点 7/8 入雾(非豁免——入口 BFS 层差 > N)〔fv1〕", not vis_ids.has(7) and not vis_ids.has(8)) + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# ==================== GP+商店房冒烟 ==================== +# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 + +# ---- ① GP 冒险钱包·到账 ---- + + +# ==================== GP+商店房冒烟 ==================== +# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 + +# ---- ① GP 冒险钱包·到账 ---- + +func _smoke_gp_wallet() -> void: + print("--- GP 冒险钱包·到账(战斗完成入账恰一次 + Boss 加值)---") + var autoplay = load("res://smoke/level_autoplay.gd") + # ① L1 首战(普通战档——掷定 [2,4] + 关级加值 0):runner 驱动,收集 battle_finished 与钱包事件 + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + runner.set_hero_override(200, 100) + var fin := {} + var wallet_events: Array = [] + runner.wallet_changed.connect(func(ev, gp_val, extra): wallet_events.append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))})) + Kit.check("GP 到账:L1 启动(普通战档)", runner.start_level(1, 42)) + var battle = runner.current_battle() + var rows: Array = [] + battle.step_row.connect(func(row): rows.append(row)) + battle.battle_finished.connect(func(p): fin["p"] = p) + var st := _battle_st("normal") + var guard := 30 + while runner.current_battle() == battle and not fin.has("p") and guard > 0: + guard -= 1 + autoplay._autoplay_battle(runner, battle, st, null) + Kit.check("L1 首战胜利(battle_finished gp_dropped 载荷在案)", fin.has("p") and String(fin["p"].get("result", "")) == "victory") + var escrow_sum := _escrow_sum_of(rows) + var gp_dropped: int = int(fin["p"].get("gp_dropped", -999)) + Kit.check("L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" % [gp_dropped - escrow_sum, escrow_sum], gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4) + var gains: Array = [] + for ev in wallet_events: + if ev["event"] == "gain": + gains.append(ev) + Kit.check("钱包恰入账一次(gain 事件 ×%d)" % gains.size(), gains.size() == 1) + if gains.size() == 1: + Kit.check("入账金额 = gp_dropped 载荷(%d)" % gp_dropped, int(gains[0]["amount"]) == gp_dropped) + Kit.check("钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" % runner.gp, runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"])) + # ② L2 Boss 战(节点 9——商店房批重编号):掷定 [6,10] + 关级加值 1 + var boss = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(42)) + var rows_b: Array = [] + var fin_b := {} + boss.step_row.connect(func(row): rows_b.append(row)) + boss.battle_finished.connect(func(p): fin_b["p"] = p) + Kit.check("L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)", boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)})) + var guard_b := 60 + while boss.battle_result == "" and guard_b > 0: + guard_b -= 1 + boss.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" % String(boss.battle_result), fin_b.has("p")) + var escrow_b := _escrow_sum_of(rows_b) + var gp_b: int = int(fin_b.get("p", {}).get("gp_dropped", -999)) if fin_b.has("p") else -999 + Kit.check("L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" % [gp_b - escrow_b, escrow_b], gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11) + + +# 步骤流中 GP 型兑付行(effect_id gp_gain/gp_loss)的 value 代数和 = escrow 会计算(归因行随行携带) + + +# 步骤流中 GP 型兑付行(effect_id gp_gain/gp_loss)的 value 代数和 = escrow 会计算(归因行随行携带) +func _escrow_sum_of(rows: Array) -> int: + var sum := 0 + for row in rows: + if String(row.get("effect_id", "")) == "gp_gain" or String(row.get("effect_id", "")) == "gp_loss": + sum += int(row.get("value", 0)) + return sum + + +# ---- ② escrow 隔离(GP安全格兑付 / 破财直验 / 钱包 clamp)---- + + +# ---- ② escrow 隔离(GP安全格兑付 / 破财直验 / 钱包 clamp)---- + +func _smoke_gp_escrow() -> void: + print("--- GP escrow 隔离(GP安全格兑付入缓冲 · 破财不透支)---") + # ① 种子扫描:找GP 袋(gp 型安全格)可挖掘局——挖开兑付 +3 入 escrow,战斗末随 gp_dropped 一次上抛 + var scan := {} + for s in range(1, 121): + var rng = _fresh_rng(s) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + var fin := {} + settlement.battle_finished.connect(func(p): fin["p"] = p) + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}): + continue + var target := _find_gp_dig(settlement.minesweeper, settlement) + if target.is_empty(): + continue + scan = {"seed": s, "settlement": settlement, "target": target, "rows": rows, "fin": fin} + break + Kit.check("GP 袋(gp 型安全格)可挖掘局种子可达(seed=%s)" % str(scan.get("seed", -1)), not scan.is_empty()) + if not scan.is_empty(): + var settlement2 = scan["settlement"] + var ms = settlement2.minesweeper + var t: Dictionary = scan["target"] + var dig_arr: Array = [t["dig_cell"].x, t["dig_cell"].y] + if t["hero_cell"] != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": t["hero_cell"]}]) + ms.settle_batch([{"action": "dig", "cell": t["dig_cell"]}]) + var guard_stop := 12 + while settlement2.battle_result == "" and guard_stop > 0: + guard_stop -= 1 + ms.settle_batch([{"action": "stop"}]) # 收轮结算:授予段兑付 +3 → 战斗段先手 999 清场 → 胜利上抛 + Kit.check("GP局战斗终局(先手 999 收束)", settlement2.battle_result == "victory") + var gain_ok := false + for row in scan["rows"]: + if String(row.get("effect_id", "")) == "gp_gain" and row.get("origin_cell", []) == dig_arr and int(row.get("value", 0)) == 3: + gain_ok = true + Kit.check("GP 袋兑付行在案(GP 袋 +3 → 战斗内缓冲,origin 指回来源格)", gain_ok) + Kit.check("escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" % int(settlement2.escrow), int(settlement2.escrow) >= 3) + var gp_d: int = int(scan["fin"].get("p", {}).get("gp_dropped", -999)) + Kit.check("格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" % [gp_d, int(settlement2.escrow)], gp_d - int(settlement2.escrow) >= 2 and gp_d - int(settlement2.escrow) <= 4) + # ② 破财(gp_loss)直验:兑付入口直调(weight 0 不入池——字典负向行单测通道,负向字典冒烟前例) + var rng_l = _fresh_rng(20260921) + var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l) + var fin_l := {} + settle_l.battle_finished.connect(func(p): fin_l["p"] = p) + Kit.check("破财直验战斗启动", settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"})) + for i in range(3): + settle_l._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, + "negative", "mine_hit", Vector2i(0, 0), 1) + Kit.check("破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)", int(settle_l.escrow) == -6) + settle_l.minesweeper.settle_batch([{"action": "stop"}]) + var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999)) + var boss_drop: Dictionary = _db.shop_gp_drop_row("boss") + var boss_bonus1: int = _db.shop_level_gp_bonus(1) + Kit.check("破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" % [ + gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1], + gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1) + # ③ 钱包不透支(clamp ≥0):runner 驱动 L1 首战 + 战斗中注入破财 → gp_dropped 负值 → 钱包钳制归 0 + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(4242)) + runner.set_hero_override(49850, 0) # atk 2 → 999(快速终局;hp 基线) + var ev_box := {"gains": [], "min_gp": 999999} + runner.wallet_changed.connect(func(ev, gp_val, extra): + ev_box["gains"].append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))}) + ev_box["min_gp"] = mini(ev_box["min_gp"], int(gp_val))) + Kit.check("clamp 场景:L1 启动(冒险钱包开局 0)", runner.start_level(1, 4242)) + var b3 = runner.current_battle() + var fin3 := {} + b3.battle_finished.connect(func(p): fin3["p"] = p) + for i in range(3): + b3._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, + "negative", "mine_hit", Vector2i(0, 0), 1) + var guard3 := 12 + while runner.current_battle() == b3 and not fin3.has("p") and guard3 > 0: + guard3 -= 1 + b3.minesweeper.settle_batch([{"action": "stop"}]) + var gp3: int = int(fin3.get("p", {}).get("gp_dropped", 999)) + Kit.check("破财压制入账载荷为负(gp_dropped=%d)" % gp3, gp3 < 0) + var gain3: Array = [] + for ev in ev_box["gains"]: + if ev["event"] == "gain": + gain3.append(ev) + Kit.check("破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" % gp3, gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0) + Kit.check("钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" % [runner.gp, int(ev_box["min_gp"])], runner.gp == 0 and int(ev_box["min_gp"]) >= 0) + + +# 板真相直读找GP 袋挖掘目标:未开安全格 + gp 型资源身份 + 存在可达已开邻格(_find_seal_target 同款口径) + + +# 板真相直读找GP 袋挖掘目标:未开安全格 + gp 型资源身份 + 存在可达已开邻格(_find_seal_target 同款口径) +func _find_gp_dig(minesweeper, settlement) -> Dictionary: + var board = minesweeper.current_board() + var revealed := {} + for c in minesweeper.revealed_cells(): + revealed[c] = true + for y in board.height: + for x in board.width: + var c := Vector2i(x, y) + if revealed.has(c) or board.is_mine(c): + continue + var ident = settlement.query_reward_identity(c) + if ident == null or String(ident.get("cell_type", "")) != "gp": + continue + for n in board.neighbors8(c): + if revealed.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()): + return {"dig_cell": c, "hero_cell": n} + return {} + + +# ---- ③ 商店房三收口 ---- + + +# ---- ③ 商店房三收口 ---- + +func _smoke_shop_room() -> void: + print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---") + var autoplay = load("res://smoke/level_autoplay.gd") + # 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) + var shop_run := {} + for s in range(42, 92): + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) + runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) + runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本 + var st := _battle_st("normal") + var guard := 40 + while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0: + guard -= 1 + if runner.awaiting_choice(): + var ids: Array = runner.next_candidate_ids() + ids.sort() + runner.pick_next_node(int(ids[0])) + continue + var t := String(runner.current_node().get("type", "")) + if t == "reward" or t == "rest": + runner.continue_after_node() + continue + if t == "shop": + break + var b = runner.current_battle() + if b == null: + break + var fin := {} + b.battle_finished.connect(func(p): fin["p"] = p) + var g2 := 80 + while runner.current_battle() == b and not fin.has("p") and g2 > 0: + g2 -= 1 + autoplay._autoplay_battle(runner, b, st, null) + if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16: + shop_run = {"seed": s, "runner": runner} + break + Kit.check("商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1)), not shop_run.is_empty()) + if shop_run.is_empty(): + return + var runner = shop_run["runner"] + Kit.check("L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)", String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8) + Kit.check("到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)", not runner.awaiting_choice() and runner.next_candidate_ids().is_empty()) + # 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数 + var prods: Array = runner.shop_products() + Kit.check("三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)", prods.size() == 3) + var by_id := _shop_products_by_id(prods) + Kit.check("L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)", int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6) + # 购买 atk_up:钱包 −6 · 加算区 atk +1 · 二购现价 8 + var gp0: int = runner.gp + Kit.check("购买 atk_up 受理(现价 6)", runner.buy_product("atk_up")) + Kit.check("钱包扣现价 6(%d → %d)" % [gp0, runner.gp], runner.gp == gp0 - 6) + # 加算区住 RunLedger + Kit.check("统一冒险加算区 atk +1(bonus={atk,max_hp,armor_class} 账本)", int(runner.run_ledger.bonus.get("atk", -1)) == 1 and int(runner.run_ledger.bonus.get("max_hp", -1)) == 0 and int(runner.run_ledger.bonus.get("armor_class", -1)) == 0) + by_id = _shop_products_by_id(runner.shop_products()) + Kit.check("atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)", int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1) + # 行进段百分比归零(购买/生效断言按加算区裸值口径——调试浮窗百分比后乘;hp 快照钳到新口径上限防 spawn 越界,测试通道) + runner.set_hero_override(0, 0) + runner.hero_hp = mini(runner.hero_hp, runner.effective_max_hp()) + # hp_up:上限与当前生命同步 +3(封顶新上限) + var hp_before: int = runner.hero_hp + var mhp_before: int = runner.effective_max_hp() + Kit.check("购买 hp_up 受理(现价 4)", runner.buy_product("hp_up")) + Kit.check("钱包扣 4(%d)" % runner.gp, runner.gp == gp0 - 6 - 4) + Kit.check("生命上限 +3(%d → %d——加算区 max_hp 轴)" % [mhp_before, runner.effective_max_hp()], runner.effective_max_hp() == mhp_before + 3) + Kit.check("当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp], runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp())) + # armor_up:AC 10+1 + Kit.check("购买 armor_up 受理(现价 6)", runner.buy_product("armor_up")) + Kit.check("AC = 10+1(armor_class 加算区轴,无百分比通道)", runner.effective_ac() == int(_db.hero.armor_class) + 1) + # 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝 + var guard_drain := 20 + var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环 + while guard_drain > 0: + guard_drain -= 1 + var hp_price: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"]) + if runner.gp < hp_price: + break + if not runner.buy_product("hp_up"): + break + hp_buys += 1 + var gp_dry: int = runner.gp + var tier_dry: int = runner.purchased_tier("hp_up") + var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"]) + Kit.check("余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry], not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry) + # 离开 → 回选路口(候选 [9]) + Kit.check("离开商店 → 回选路口(候选 [9]——零自动推进收口)", runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9]) + # 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案) + Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.hero.atk) + 1) + Kit.check("选 Boss(节点 9)开战", runner.pick_next_node(9) and runner.current_battle() != null) + var snap: Dictionary = runner.current_battle().presentation_snapshot() + # max_hp 期望按实际购买笔数推导(可购笔数 + # 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不锚死恰好 1 笔) + Kit.check("战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys], int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys) + # 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零 + var fin_end := {} + runner.level_finished.connect(func(p): fin_end["p"] = p) + var g3 := 60 + while not fin_end.has("p") and g3 > 0: + g3 -= 1 + var b_end = runner.current_battle() + if b_end == null: + break + autoplay._autoplay_battle(runner, b_end, _battle_st("normal"), null) + Kit.check("冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", "")), fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"]) + Kit.check("冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)", runner.gp == 0) + + +func _shop_products_by_id(prods: Array) -> Dictionary: + var out := {} + for p in prods: + out[String(p["product_id"])] = p + return out + + +# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- + + +# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- + +func _smoke_shop_graph() -> void: + print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---") + for L in [2, 3]: + var row := {} + for r in _db.level.rows: + if int(r.level_index) == L: + row = r + var shops: Array = [] + var boss_id := -1 + var max_id := -1 + for n in _level_active_nodes(L): + var t := String(n.get("type", "")) + if t == "shop": + shops.append(int(n.get("node_depth", -1))) + if t == "boss": + boss_id = int(n.get("node_depth", -1)) + max_id = maxi(max_id, int(n.get("node_depth", -1))) + Kit.check("L%d 商店房恰 1 且 node_depth=8(next=[9])" % L, shops.size() == 1 and int(shops[0]) == 8) + Kit.check("L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L, boss_id == 9 and boss_id == max_id) + var paths: Array = _all_entry_boss_paths(_level_active_nodes(L)) + var via_shop := true + for p in paths: + if not p.has(8): + via_shop = false + Kit.check("L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()], paths.size() >= 2 and via_shop) + # 跨表校验:(·,boss,9) Boss 行精确存在 · (·,battle,8) 无战斗行残留(陈旧行清理确认) + var bg_keys := {} + for row in _db.board_gen.rows: + bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var en_keys := {} + for row in _db.enemy.rows: + en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true + for L in [2, 3]: + Kit.check("(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" % L, bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L)) + Kit.check("(%d,battle,8) 商店房无板/战斗行集残留(陈旧行已清理)" % L, not bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L)) + Kit.check("L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)", bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8")) + + +# ---- ⑤ 商店共享池(三选一同构/乘法价/跨渠道共账/切边/断点扩键)---- + + +# ---- ⑤ 商店共享池(三选一同构/乘法价/跨渠道共账/切边/断点扩键)---- + +func _smoke_shop_pool() -> void: + print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---") + var autoplay = load("res://smoke/level_autoplay.gd") + var shelf_kinds: Array = [] + for row in _db.shop_pool_rows(): + shelf_kinds.append(String(row.get("kind_id", ""))) + # ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架) + var scen := {} + var first_shelf_a: Array = [] + var first_shelf_b: Array = [] + for s in range(42, 92): + var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s)) + a8.set_hero_override(900, 400) + if not a8.start_adventure(s, {"first_script": true, "shop_mode": "pool"}): + continue + if not _adv_drive_to_shop(a8, _adv_st(), autoplay): + continue + scen = {"a": a8, "s": s, "r": a8.runner()} + first_shelf_a = (a8.runner().shop_shelf() as Array).duplicate(true) + break + Kit.check("池模式商店房场景可达(seed=%s)" % str(scen.get("s", -1)), not scen.is_empty()) + if not scen.is_empty(): + # 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面) + var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"]))) + a8b.set_hero_override(900, 400) + if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \ + and _adv_drive_to_shop(a8b, _adv_st(), autoplay): + first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true) + Kit.check("进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size(), first_shelf_a.size() == 3) + # MC-1 商店独立刷新(P-C2+Q2 裁——基础免费 0;GP 刷新 ceil(20×1.8^n) provisional;重抽同源) + if not scen.is_empty(): + var r8 = scen["r"] + var gp0: int = int(r8.gp) + var cost0: int = r8.shop_refresh_cost() + Kit.check("刷新首价=基价(%d)" % cost0, cost0 == int(_db.adventure.shop_refresh_cost_base)) + Kit.check("基础免费 0 次(Q2——天赋增益位后续添加)", r8.shop_refresh_free_left() == 0) + var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true) + Kit.check("GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0], r8.shop_refresh()) + Kit.check("GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)], int(r8.gp) == gp0 - cost0) + Kit.check("刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()), int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve)))) + var same_shelf := first_shelf_a.size() == first_shelf_b.size() + if same_shelf: + for i in first_shelf_a.size(): + if String(first_shelf_a[i].get("kind_id", "")) != String(first_shelf_b[i].get("kind_id", "")) \ + or int(first_shelf_a[i].get("price", -1)) != int(first_shelf_b[i].get("price", -1)): + same_shelf = false + break + Kit.check("同种子同货架逐位(确定性重放承载——断点货架续玩语义)", same_shelf) + if scen.is_empty(): + return + var a8 = scen["a"] + var r8 = scen["r"] + # ② 货架投影形制:kind 词表封闭 + 现价公式推导(ceil(base×b^(关序−1)×A^0)——核心现算,呈现零复算) + var l2 := int(r8.level_row.level_index) + for c in r8.shop_shelf(): + # T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉 + # (数值卡走 shop_pool_row 推导 / relic 卡走 15 固定锚——同公式不同基价) + var ckind := String(c.get("kind_id", "")) + var is_relic := ckind == "relic" + Kit.check("货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind, is_relic or shelf_kinds.has(ckind)) + var expect_p: int + if is_relic: + expect_p = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + else: + var prow: Dictionary = _db.shop_pool_row(ckind) + expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + Kit.check("首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p], int(c.get("price", -1)) == expect_p) + # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体) + var buy_c: Dictionary = {} + for c in r8.shop_shelf(): + if int(c.get("price", 1 << 30)) <= int(r8.gp): + buy_c = c + break + Kit.check("可购卡在架(GP %d——种子扫描保证首购空间)" % int(r8.gp), not buy_c.is_empty()) + if not buy_c.is_empty(): + var kind := String(buy_c.get("kind_id", "")) + var tier := String(buy_c.get("tier", "")) + var val_map := {"attack": "atk", "max_hp": "max_hp", "armor": "armor_class", "ap": "ap"} + var gp0: int = int(r8.gp) + var taken0: int = int(a8._curio_taken.get(kind, 0)) + var val0: int = int(a8.run_ledger.bonus.get(String(val_map[kind]), -999)) + Kit.check("购买受理(pool 口——%s|%s)" % [kind, tier], r8.buy_pool_item(kind)) + Kit.check("钱包扣现价(%d → %d)" % [gp0, int(r8.gp)], int(r8.gp) == gp0 - int(buy_c.get("price", 0))) + Kit.check("加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))], int(a8.run_ledger.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.curio_value(kind, tier))) + Kit.check("共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._curio_taken.get(kind, 0))], int(a8._curio_taken.get(kind, 0)) == taken0 + 1) + # A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式) + # A 链:白盒抬 GP 保证第二购场景确定构造(直改测试基建——非生产行为) + r8.gp = int(r8.gp) + 64 + var second: Dictionary = {} + for c in r8.shop_shelf(): + if int(c.get("price", 1 << 30)) <= int(r8.gp): + second = c + break + if not second.is_empty(): + var prow2: Dictionary = _db.shop_pool_row(String(second.get("kind_id", ""))) + var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * _db.shop_price_a())) + Kit.check("A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2, int(second.get("price", -1)) == expect_p2) + Kit.check("同 visit 第二购受理(A 链软闸在价不在限)", r8.buy_pool_item(String(second.get("kind_id", "")))) + else: + Kit.check("A 链爬价场景未构造(抬 GP 后货架仍无可购第二卡)——场景绑定失败", false) + # ④ 切边零入店(白-box 重抽 40 次——GP 袋/规则改写型特性永不在架;重抽消费 shop_draw 签,场景尾不回写主时间线) + var cut_seen := false + for i in 40: + r8._shop_draw_shelf() + for c in r8.shop_shelf(): + if String(c.get("kind_id", "")) == "gp" or String(c.get("kind_id", "")) == "treasure_item": + cut_seen = true + Kit.check("切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)", not cut_seen) + # ⑤ 池空硬闸(库存尽剔出牌库——TP0:white-box 耗尽 attack 令牌后货架零 attack + 消费口拒绝) + for di8 in range(a8._curio_deck.size() - 1, -1, -1): + if String((a8._curio_deck[di8] as Dictionary).get("kind", "")) == "attack": + a8._curio_deck.remove_at(di8) + var attack_seen := false + for i in 40: + r8._shop_draw_shelf() + if (r8.shop_shelf() as Array).any(func(c): return String(c.get("kind_id", "")) == "attack"): + attack_seen = true + break + Kit.check("库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)", not attack_seen) + var stock_before: int = a8.pool_stock("attack") + Kit.check("池空消费拒绝(handler 零副作用——买光即枯竭硬闸)", not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before) + # ⑥ 跨渠道共库 shop→reward 向:回填一枚 attack 令牌 → 消费口受理 → 奖励牌库同步摘牌 + a8._curio_deck.append({"kind": "attack", "tier": "common"}) + Kit.check("商店消费口受理(_apply_curio_pick 同口——真单点库)", a8._consume_pool_purchase("attack", "common")) + var reward_attack_seen := false + for t6 in a8._draw_curio_candidates(): + if String((t6 as Dictionary).get("kind", "")) == "attack": + reward_attack_seen = true + break + Kit.check("商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)", not reward_attack_seen) + # ⑦ 断点扩键(P2 甲——save v4 域内:shop_shelf/shop_visit_buys 入快照;离开店=空架合法态) + var sm = load("res://core/save_manager.gd").new("user://_smoke_shop_pool_save.json") + sm.load_from_disk() + a8.set_save_manager(sm) + Kit.check("离开商店 → 回选路口(货架态清零落档——零自动推进收口)", r8.continue_after_node() and r8.awaiting_choice()) + var bp: Dictionary = sm.breakpoint_snapshot() + Kit.check("断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)", bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty()) + Kit.check("断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)", bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0) + sm.clear_breakpoint() + # ⑧ 终局池读数(run_adventure pool 面——商店与奖励跨渠道消耗后的终局余量出讫) + var st_pool: Dictionary = autoplay.run_adventure(_db, 42, 0, 0, false, null, "normal", "min", {}, {}, {"mode": "pool"}) + var ps: Dictionary = st_pool.get("pool_stock", {}) + Kit.check("终局池读数六键出讫(run_adventure pool 面——四池种+T5 专长凭证+T6 heal 行,读数只锚一次载体)", ps.size() == 6) + var ps_ok := true + for k in ps.keys(): + var quota := 0 + for tier in _db.adventure.TIERS: + quota += int(_db.adventure.curio_quota(String(k), tier)) + if int(ps[k]) < 0 or int(ps[k]) > quota: + ps_ok = false + Kit.check("终局池余量在域 [0,配额](%s)" % str(ps), ps_ok) + + +# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限) + + +# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排) +func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: + var guard := 24 + while guard > 0 and String(st["result"]) == "": + guard -= 1 + autoplay._drive_level(a.runner(), st, null, "normal", "min") + if String(st["result"]) != "": + return false + if a._pending_next_level: + # 三选一停点确认(确定性选件后推进,与 run_adventure 自动策略同口径;TP0 令牌化) + if not a._curio_pending.is_empty(): + var bcands: Array = a._curio_pending["candidates"] + var bpick: Dictionary = autoplay._pick_token(bcands) + a.pick_curio(String(bpick.get("kind", "")), String(bpick.get("tier", "")), String(bpick.get("feat_id", ""))) + a.continue_to_next_level() + continue + var r = a.runner() + if String(r.current_node().get("type", "")) == "shop": + return true + if r.awaiting_choice(): + var ids: Array = r.next_candidate_ids() + ids.sort() + r.pick_next_node(int(ids[0])) + continue + var t := String(r.current_node().get("type", "")) + if t == "reward": + # 奖励节点三选一(确定性选件、替换放弃新件、兑付确认回图;TP0 令牌化) + var ndraw: Dictionary = a.node_curio_candidates() + var ncands: Array = ndraw.get("candidates", []) as Array + if not ncands.is_empty(): + a.present_node_curio(ncands) + var npick: Dictionary = autoplay._pick_token(ncands) + a.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("feat_id", ""))) + r.continue_after_node() + elif t == "rest": + r.continue_after_node() + return false + + +# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集) + + +# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集) +func _adv_st() -> Dictionary: + return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0, + "rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "", + "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "", + "policy": "normal", "refreshes": 0, "mine_hits": 0, "visited_ids": [], "choices": 0} + + + +# ---- 未开格类型微光·数据侧断言(V1 作用集恒等/V2 九型归组封闭/V7 零玩法写入)---- +# 夹具纪律:身份回调形状与 reward_economy.query_identity 同构({reward_id, cell_type, tier}); +# 位点覆盖表 _glow_type_overrides(Vector2i → cell_type)+封锁集 _glow_blocked(Vector2i → true, +# 返回 null = 不在摇号清单的封锁格)。哈希兜底按坐标确定性散列——全缸可复现。 + + +# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限) +func _all_entry_boss_paths(nodes: Array) -> Array: + var by_id := {} + var inbound := {} + for n in nodes: + by_id[int(n.get("node_depth", -1))] = n + for n in nodes: + for tid in n.get("next", []): + inbound[int(tid)] = true + var entry := -1 + for n in nodes: + if not inbound.has(int(n.get("node_depth", -1))): + entry = int(n.get("node_depth", -1)) + var paths: Array = [] + var stack: Array = [[entry]] + while not stack.is_empty(): + var path: Array = stack.pop_back() + var cur: Dictionary = by_id[path[path.size() - 1]] + var outs: Array = cur.get("next", []) + if outs.is_empty(): + paths.append(path) + continue + for t in outs: + var np := path.duplicate() + np.append(int(t)) + stack.append(np) + return paths + + + +# ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; +# conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- + + +# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) +func _level_active_nodes(level_index: int) -> Array: + for row in _db.level.rows: + if int(row.level_index) == level_index: + var tpls: Array = row.get("templates", []) + if not tpls.is_empty(): + return tpls[0].get("nodes", []) + return row.get("nodes", []) + return [] + + +# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 +func _first_encounter_band(level_index: int, enemy_id: String) -> int: + var best := -1 + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) != level_index: + continue + if String(row.get("row_kind", "")) != "battle" or String(row.get("enemy_id", "")) != enemy_id: + continue + var band := int(row.get("depth_band", -1)) + if best < 0 or band < best: + best = band + return best + + +# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) + + +# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) +func _boss_depth(level_index: int) -> int: + for n in _level_active_nodes(level_index): + if String(n.get("type", "")) == "boss": + return int(n.get("node_depth", -1)) + return -1 + + +# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 + + +func _has_err_category(errs: Array, category: String) -> bool: + for e in errs: + if String(e.get("category", "")) == category: + return true + return false + + +# ⑦ 实装探针(妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔): +# 停手轮驱动 (2,9)——零挖掘零封印 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行 +# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。 diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd new file mode 100644 index 00000000..098ff6c7 --- /dev/null +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -0,0 +1,734 @@ +# tests/unit/core/meta_progression_forge_test.gd — 元进度与锻造单元测试宿主 +# 断言面 = 六套(批 B 断言迁移自 smoke_all.gd,G9 施工组;六套按源编排序迁入): +# 元进度·树购买面(购买受理/持久/余额不足拒绝/未知节点拒绝——行价=表值 flat 单价) +# 元进度·收束入账与工坊(Boss 两档/钥石不丢/秘宝解锁/摘要副本与 cap/同调位/开局传参面) +# 元进度·注入可见与白板(hero_override 公式面/三轴白板逐位一致) +# 锻造工坊·两根类型轴所有权移交(静态表两轴删除/写口族/并联双通道/两币分工/注入接线/三层 AC) +# 天赋树(12 节点 schema 反例/购买面行门槛/Fork 一节点双态/彩蛋觉醒) +# 天赋树六键行为(先手窗/危险直觉限次/守侧五场景/财富之道聚散) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断在此展开, +# 表值变异的红由具体断言落,承 G2/G3/G4/G5 同款);全局 class_name(Attunement/EffectEngine/ +# EffectContext)裸用(--script 模式全局类在册,G3 先例)。 +# 运行:godot --headless --path game --script res://tests/unit/core/meta_progression_forge_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[meta_progression_forge_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排序(_smoke_meta 薄壳序 + _run_core_smoke 尾段序) + _meta_pricing_and_buy() + _meta_settle_and_workshop() + _meta_injection() + _smoke_forge_axes() + _smoke_tree() + _smoke_tree_rules() + quit(Kit.finish("meta_progression_forge_test")) + + +# ---- 隔离夹具(smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)---- + +# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 user://save.json 真实档) +const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json", + "user://smoke_meta_settle.json", "user://smoke_meta_workshop.json", + "user://smoke_meta_cap.json", "user://smoke_meta_white.json", + "user://smoke_meta_attune.json", "user://smoke_meta_attune2.json", + "user://smoke_meta_tree.json", "user://smoke_meta_career.json", + "user://smoke_meta_career_v5.json", "user://smoke_meta_treasure_unlock.json", "user://smoke_meta_treasure_blank.json", + "user://smoke_meta_forge.json", "user://smoke_meta_forge2.json", "user://smoke_meta_forge3.json", + "user://smoke_meta_forge4.json", "user://smoke_meta_forge5.json"] + + +func _meta_smoke_cleanup() -> void: + for p in _META_SMOKE_PATHS: + for suffix in ["", ".tmp", ".corrupt.bak"]: + if FileAccess.file_exists(p + suffix): + DirAccess.remove_absolute(p + suffix) + + +func _meta_new_save(path: String): + var sv = load("res://core/save_manager.gd").new(path) + sv.load_from_disk() + return sv + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# ---- 全战斗回放驱动(过渡副本——G6 属主件 `_run_battle`/`_auto_play_round` 的宿主侧一致副本: +# forge_axes「enc 两轴直达 spawn 英雄快照」需真实流驱动而 G6 宿主未建;smoke_all.gd 原件保留 +# 供未迁套消费,收尾批与原件去重。副本去 G6 专属「收束相位回 Idle」断言——该条随 `_run_battle` +# 本体迁其宿主,本副本保持零断言纯驱动形态)---- +func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: + # 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false} + settlement.step_row.connect(func(row): out.stream.append(row)) + settlement.battle_finished.connect(func(payload): out.result = payload.get("result", "")) + settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) + settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) + settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) + if not settlement.begin_battle(enc): + out.stream.append({"type": "verdict", "value": "begin_failed"}) + return out + var max_rounds := 40 + while out.result == "" and out.rounds < max_rounds: + out.rounds += 1 + _auto_play_round(settlement, out) + if out.result == "": + out.result = "timeout" + out.terminated = out.result == "victory" or out.result == "defeat" + return out + + +func _auto_play_round(settlement, out: Dictionary) -> void: + var minesweeper = settlement.minesweeper + var action_economy = settlement.action_economy + var start_round: int = settlement.round_index + # 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交) + var guard := 400 + while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "": + guard -= 1 + var plan := AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if action_economy.phase != "Active" or settlement.round_index != start_round: + break + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "": + minesweeper.settle_batch([{"action": "stop"}]) + + +# ---- 元进度 ②树购买面(购买受理/持久/余额不足拒绝/未知节点拒绝; +# 行价=表值 flat 单价)---- +func _meta_pricing_and_buy() -> void: + print("--- 元进度·树购买面(隔离档 smoke_meta_buy/dry)---") + _meta_smoke_cleanup() + var sv = _meta_new_save(_META_SMOKE_PATHS[0]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + # ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久(树全钥石单币) + sv.add_keystone(1000) + var bal0: int = sv.keystone_balance() + var price0: int = int(_db.tree_node("sp_hp").get("price", 0)) + Kit.check("树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0], + mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0) + var sv_r = _meta_new_save(_META_SMOKE_PATHS[0]) + Kit.check("购买持久(reload 后级与余额不变)", + sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0) + # ③ 余额不足 / 未知节点:false 零副作用 + var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1]) + var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry) + var tn_before: Dictionary = sv_dry.tree_nodes() + var fs_before: Dictionary = sv_dry.fork_sides() + Kit.check("余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)", + not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 + and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before) + Kit.check("未知节点 false 零副作用(词表外出域)", not mp_dry.tree_buy("no_such_node")) + + +# ---- 元进度 ⑥Boss 两档 ⑦钥石不丢 ⑧工坊解锁 ⑩摘要副本与 cap(合成摘要驱动——settle 纯账面零跑局)---- +func _meta_settle_and_workshop() -> void: + print("--- 元进度·收束入账与工坊(隔离档 smoke_meta_settle/workshop/cap)---") + var sv = _meta_new_save(_META_SMOKE_PATHS[2]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + var row1: Dictionary = _db.meta_boss_keystone_row(1) + var row2: Dictionary = _db.meta_boss_keystone_row(2) + # ⑥ 逐杀掉落收束入账(掉落已在冒险内逐杀入账〔summary.keystone_total 含全部〕, + # keystone_drops 明细随摘要归档;首杀标记仍由 apply 逐通关关次置位) + var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": int(row1.get("first_kill", 0)), + "keystone_drops": [{"level": 1, "first": true, "amount": int(row1.get("first_kill", 0))}], + "master_seed": 11}) + var drop1: Dictionary = d1["boss_drops"][0] + Kit.check("新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)", + bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1)) + and int(drop1.get("level", 0)) == 1) + Kit.check("明细 total = keystone_total(逐杀掉落全源——无二次派生加账)", + int(d1.get("total", 0)) == int(row1.get("first_kill", 0))) + Kit.check("余额 = 累计入账(逐杀掉落合计——无重复加账)", + sv.keystone_balance() == int(row1.get("first_kill", 0))) + # ⑥ 再通转复杀档(首杀标记置位后小额恒定不递减——标记面在 apply,金额面在逐杀裁决) + var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)), + "keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}], + "master_seed": 12}) + var drop2: Dictionary = d2["boss_drops"][0] + Kit.check("再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)", + not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1))) + var d3: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)), + "keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}], + "master_seed": 13}) + Kit.check("重复杀第三轮恒定不递减(验收5——无衰减曲线)", + not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1))) + # 混合归因:通关两关 → L1 重复 + L2 首杀(明细随摘要逐笔归档) + var d4: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2, + "stopped_at": 0, "keystone_total": int(row1.get("repeat_kill", 0)) + int(row2.get("first_kill", 0)), + "keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}, + {"level": 2, "first": true, "amount": int(row2.get("first_kill", 0))}], + "master_seed": 14}) + Kit.check("两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)", + not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) + and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1))) + # ⑦ 败北零通关摘要:余额只增不减(零入账)、clear_counts 不变 + var bal_before: int = sv.keystone_balance() + var cc1_before: int = sv.clear_count(1) + var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, + "stopped_at": 1, "keystone_total": 0, "master_seed": 15}) + Kit.check("败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)", + dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before) + # ⑧ 秘宝解锁服务(V3 钥石购买/V4 条件解锁;注入=直调解锁写口+直置存档生涯计数, + # 不走正常资源积累流程——卡片 7.1 测试方式) + var sv_u = _meta_new_save(_META_SMOKE_PATHS[11]) + var mp_u = load("res://core/meta_progression.gd").new(_db, sv_u) + sv_u.add_keystone(10) + Kit.check("条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕", + not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye") + and not mp_u.buy_treasure_unlock("ranged_pick")) + Kit.check("余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕", + not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10) + Kit.check("钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕", + mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") + and sv_u.keystone_balance() == 0) + Kit.check("已解锁重复购买拒绝〔秘宝批 V3〕", + not mp_u.buy_treasure_unlock("morning_order")) + Kit.check("条件未达不解锁(远程镐=通关 1 次)〔秘宝批 V4〕", + mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick")) + sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径) + Kit.check("远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:通关=打通整局冒险)〔秘宝批 V4〕", + (mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick") + and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) + sv_u._data["meta"]["boss_kill_count"] = 3 + Kit.check("刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕", + (mp_u.refresh_treasure_unlocks() as Array).has("refresh_license")) + sv_u._data["meta"]["adventure_cleared"] = 3 + Kit.check("天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕", + not sv_u.item_unlocked("sky_eye")) + sv_u._data["meta"]["boss_kill_count"] = 4 + Kit.check("天眼双条件 AND(通关≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕", + (mp_u.refresh_treasure_unlocks() as Array).has("sky_eye")) + sv_u._data["meta"]["vision_used"] = 10 + Kit.check("信标网络=真视类工具累计使用≥10(行为计数新域——use_treasure 受理成功累计)〔秘宝批 V4〕", + (mp_u.refresh_treasure_unlocks() as Array).has("beacon_network")) + var r_n := 0 + for st_row in mp_u.treasure_shop_status(): + r_n += 1 if bool(st_row.get("unlocked", false)) else 0 + Kit.check("解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕", r_n == 5) + # ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10 + var rs: Array = sv.recent_summaries() + Kit.check("摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)", + rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") + and (rs[1] as Dictionary).has("keystone_drops") + and int(rs[0].get("master_seed", -1)) == 15) + var sv_c = _meta_new_save(_META_SMOKE_PATHS[4]) + var mp_c = load("res://core/meta_progression.gd").new(_db, sv_c) + for i in 12: + mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, + "stopped_at": 1, "keystone_total": 0, "master_seed": i}) + Kit.check("摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)", + sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11) + # ⑪ 同调位账本(装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段) + var sv_a = _meta_new_save(_META_SMOKE_PATHS[6]) + var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a) + Kit.check("白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)", + Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0) + Kit.check("满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕", + mp_a.toggle_attunement("morning_order") == false) + sv_a.add_keystone(200) + sv_a.unlock_item("morning_order") + sv_a.unlock_item("twin_loader") + Kit.check("Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)", + mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 + and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1) + mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 0, "master_seed": 21}) + Kit.check("通关 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕", + Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1) + sv_a.set_tree_level("att_socket1", 1) + Kit.check("树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)", + Attunement.slots(_db, sv_a) == 1) + Kit.check("稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕", + not mp_a.toggle_attunement("sky_eye")) + Kit.check("装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)〔秘宝批 V5 换主语〕", + mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("twin_loader") == false) + Kit.check("卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕", + mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order")) + var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7]) + sv_a2.set_attunement_equipped(["morning_order"]) + var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2) + Kit.check("装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕", + (mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order")) + # ⑫ 充能重置 / 刷新双计数器 + var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12)) + Kit.check("充能重置场景:装配冒险启动(seed 12)〔秘宝批 V8 换主语〕", + a12.start_adventure(12, {"attunement": ["incense_stick", "sky_eye"]})) + Kit.check("充能账本按装备 active_charged 件重置(新目录表值:线香 battle 级 1 次/天眼 level 级 1 次——周期重置点归 V8 逐形态面)", # 充能账住 RunLedger + int(a12.run_ledger.attune_charges.get("incense_stick", -1)) == 1 and int(a12.run_ledger.attune_charges.get("sky_eye", -1)) == 1) + a12._attunement = [] + a12._attune_refresh_x = 1 + a12._attune_refresh_y = 2 + a12._curio_pending = {"candidates": [{"kind": "gp", "tier": "common"}, {"kind": "attack", "tier": "common"}, {"kind": "max_hp", "tier": "common"}], "payload": {}} + Kit.check("刷新双计数器——X 优先消耗(x1y2 → x0y2)", + a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2) + Kit.check("刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)", + a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0 + and a12.refresh_curio() == false) + a12._reset_refresh_x() + Kit.check("刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕", + a12._attune_refresh_x == 0 and a12.attune_refresh_left() == 0) + # ⑬ 主菜单开局传参面(开新/续玩两入口同调三键随开局下传防回归:真实入口曾 + # 三键恒空(缺陷①),冒烟夹具直呼 runner 参数面拦不住——此处直呼 main.build_adventure_params + # 单测统一构造面(static 免实例化整菜单场景;真实两入口共用本构造),取值与营地投影同源) + var sv_e = _meta_new_save(_META_SMOKE_PATHS[8]) + sv_e.set_attunement_equipped(["morning_order"]) + sv_e.set_attune_refresh_level(2) + sv_e.set_fork_side("fg_posture", "a") + var mp_e = load("res://core/meta_progression.gd").new(_db, sv_e) + var rules_e: Array = mp_e.tree_rule_keys() + Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)", + rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2) + var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, + {"first_script": true, "treasure_unlocked": {}, "shop_mode": "pool"}) + Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)", + (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array)) + Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)", + int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()) + Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)", + (p_new.get("tree_rules", []) as Array) == rules_e) + Kit.check("主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入", + bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool") + var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, + {"treasure_unlocked": {}}) + Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)", + (p_resume.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) + and int(p_resume.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges() + and (p_resume.get("tree_rules", []) as Array) == rules_e) + # 续玩路径 tree_rules 受理断言:直呼 runner 续玩参数面 + # (v2 旧断点夹具同式最小断点——smoke ⑫ 受理路径同形)验 _tree_rules 与传入一致。 + var adr_res = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(19)) + var bp_min: Dictionary = {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5, + "bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {}, + "template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0, + "visited_ids": []} + Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)", + adr_res.resume_adventure(bp_min, p_resume)) + Kit.check("继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)", + (adr_res._tree_rules as Array) == rules_e) + _meta_smoke_cleanup() + + +# ---- 元进度 ④注入可见 + 白板成立(验收 4/2/3——程序面;MC 逐位回归归 T7 全量验证)---- +func _meta_injection() -> void: + print("--- 元进度·注入可见与白板(隔离档 smoke_meta_white/dry)---") + var sv = _meta_new_save(_META_SMOKE_PATHS[1]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + # 树生命淬炼注入(公式:基础值+树淬炼 flat+锻造路线加值位预留 0——2 级 = +10; + # 注入走调试口径直置等级〔两分法——购买链已在 _smoke_tree 覆盖,此处隔离行门槛干扰〕) + sv.set_tree_level("sp_hp", 2) + Kit.check("生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)", + int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0))) + Kit.check("攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)", + int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0) + Kit.check("命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)", + not mp.hero_override().has("hit")) + var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) + Kit.check("注入参数冒险启动(start_adventure params 通道)", + adr.start_adventure(920251, {"hero_override": mp.hero_override()})) + var r = adr.runner() + var ov: Dictionary = mp.hero_override() + Kit.check("首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)", + int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0))) + Kit.check("首战 hero_max_hp 与开局生命账本 = 表值 + 注入档", + int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) + and int(adr.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0))) + # 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红) + var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) + Kit.check("白板冒险启动(无 params 实参——B1 空参数路径)", adr_w.start_adventure(920251)) + var sv_white = _meta_new_save(_META_SMOKE_PATHS[5]) + var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) + Kit.check("零强化参数冒险启动(新档 hero_override 全零)", + adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()})) + var rw = adr_w.runner() + var rz = adr_z.runner() + Kit.check("白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)", + int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp) + and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp()) + and int(rz.effective_ac()) == int(rw.effective_ac())) + Kit.check("ac 轴:白板恒表值、未换强化时注入零位移", + int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class)) + _meta_smoke_cleanup() + + +# ---- 锻造工坊·两根类型轴所有权移交(V5 前半:hero.tres 两轴删除+锻造配置默认值)---- +# 后半(build_adventure_params 注入默认值/选中覆盖)随 T4 注入接线同批断言;写口/解锁面归 T3。 +func _smoke_forge_axes() -> void: + print("--- 锻造工坊·类型轴所有权移交(T2)---") + # V5:hero.tres 静态表两轴已删(全表加载零违例——config_db_test 同款闸;此处钉字段不在位) + Kit.check("hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕", + not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero)) + # 锻造配置默认值 = normal/light(常驻持有——与柜台解锁状态解耦,未点亮也照常可读) + Kit.check("锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕", + _db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light") + # 单位快照回退面:spawn 三参形态(零注入)= 锻造配置默认值 + var uc = load("res://core/unit_container.gd").new(_db) + Kit.check("spawn 受理(三参形态——两轴缺省回退面)", uc.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕", + String(uc.hero_unit().get("attack_type", "")) == "normal" + and String(uc.hero_unit().get("armor_type", "")) == "light") + + # ---- T3 写口族(V2 购买/几何价 · V3 并联双通道 · V4 两币分工 · V6 选中切换)---- + # 注入口径(两分法):add_keystone 置余额/直写存档域构造状态 → 直调写口 → 断余额/等级/读回 + var sv2 = _meta_new_save(_META_SMOKE_PATHS[13]) + var mp2 = load("res://core/meta_progression.gd").new(_db, sv2) + # V2①:长剑首购=20(0 级位几何价)、等级 0→1、扣币即盘、reload 持久 + sv2.add_keystone(100) + Kit.check("长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕", + mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 + and sv2.keystone_balance() == 80) + var sv2r = _meta_new_save(_META_SMOKE_PATHS[13]) + Kit.check("购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕", + sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80) + # V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲) + Kit.check("长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕", + mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 + and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0) + # V2③:余额不足拒且零副作用(等级/选中/解锁三面零变化) + var sv2d = _meta_new_save(_META_SMOKE_PATHS[14]) + var mp2d = load("res://core/meta_progression.gd").new(_db, sv2d) + var fr_dry: Dictionary = sv2d.forge_routes_snapshot() + Kit.check("余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕", + not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 + and sv2d.forge_routes_snapshot() == fr_dry) + # V2④:已满级拒购(cap 闸);未解锁拒购 + sv2.set_forge_route_level("sword", 5) + Kit.check("已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕", + not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear")) + # V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁 + Kit.check("通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕", + mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff") + and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward")) + Kit.check("派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕", + mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 + and sv2.keystone_balance() == 30) + # V3② 通道二:一次性解锁价直购(未练满也可买——两通道互不影响) + var sv3 = _meta_new_save(_META_SMOKE_PATHS[15]) + var mp3 = load("res://core/meta_progression.gd").new(_db, sv3) + sv3.add_keystone(500) + Kit.check("通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕", + not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") + and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward")) + # V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝) + sv3.set_forge_route_level("light", 5) + Kit.check("直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕", + mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") + and sv3.keystone_balance() == 300) + # V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购) + sv2d.add_keystone(500) + Kit.check("不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕", + mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff") + and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear")) + # V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价) + var ks4: int = sv3.keystone_balance() + Kit.check("两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕", + mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 + and sv3.forge_route_level("ward") == 1) + # V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效) + var ks6: int = sv3.keystone_balance() + Kit.check("选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕", + mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" + and sv3.keystone_balance() == ks6) + Kit.check("切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕", + sv3.forge_route_level("light") == 5) + # V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁) + Kit.check("未解锁路线拒选零副作用〔锻造工坊批 V6〕", + not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword") + # V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值) + Kit.check("类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕", + mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" + and mp2d.hero_armor_type() == "light") + + # ---- T4 注入接线(V5 后半 · V6 锁死 · V7 公式对拍 · V10 树零攻防复核)---- + # V5:build_adventure_params 注入默认值(新档=树钥匙未点亮)+点亮后读存档选中覆盖 + var sv4 = _meta_new_save(_META_SMOKE_PATHS[16]) + var mp4 = load("res://core/meta_progression.gd").new(_db, sv4) + var bp_params: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4) + Kit.check("build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕", + String(bp_params.get("attack_type", "")) == "normal" + and String(bp_params.get("armor_type", "")) == "light") + sv4.add_keystone(1000) + Kit.check("长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)", + mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear")) + var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4) + Kit.check("build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕", + String(bp_sel.get("attack_type", "")) == "pierce" + and String(bp_sel.get("armor_type", "")) == "light") + # V7:hero_override 公式对拍=树 flat+锻造路线加值(旧路线练度待命不生效;切回即生效) + Kit.check("hero_override=锻造路线加值(选中长枪 Lv1→atk +1;甲 0 级→AC +0;树零分量;无 hit 键)〔锻造工坊批 V7〕", + int(mp4.hero_override().get("atk", -1)) == 1 + and int(mp4.hero_override().get("max_hp", -1)) == 0 + and int(mp4.hero_override().get("armor_class", -1)) == 0 + and not mp4.hero_override().has("hit")) + sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) + Kit.check("换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕", + int(mp4.hero_override().get("atk", -1)) == 1) + Kit.check("切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕", + mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3) + # V7 三层 AC:重甲(基础 12)+路线 Lv1 加值+局内 0——经参数面起冒险断 runner 生效值 + var sv5 = _meta_new_save(_META_SMOKE_PATHS[17]) + var mp5 = load("res://core/meta_progression.gd").new(_db, sv5) + sv5.add_keystone(1000) + Kit.check("重甲直购解锁+首购+选中(三层 AC 夹具)", + mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy")) + var adr5 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(77)) + var p5: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv5, mp5) + Kit.check("注入参数冒险启动(两轴+override 同面)", adr5.start_adventure(77, p5)) + var r5 = adr5.runner() + Kit.check("入战 AC 三层:甲型基础 12(重甲)+路线加值 1+局内 0〔锻造工坊批 V7〕", + int(r5.effective_ac()) == 12 + 1) + Kit.check("参数面快照入 runner(attack_type=normal/armor_type=heavy)〔锻造工坊批 V6〕", + adr5.hero_attack_type == "normal" and adr5.hero_armor_type == "heavy") + # V6 锁死:开冒险后改存档选中→参数面/注入值不变(快照冻结;light=初始持有已解锁可切) + Kit.check("冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕", + mp5.forge_select_armor("light") and adr5.hero_armor_type == "heavy" + and int(r5.effective_ac()) == 12 + 1) + # V5/V7 末端:enc 两轴直达 spawn 英雄快照(hero_attack 行携带选中类型键) + var run_ax = _run_battle(1, 0, {"attack_type": "pierce", "armor_type": "ward"}) + var pierce_rows := 0 + for row in run_ax.stream: + if row.get("source_class", "") == "hero_attack" and row.has("pierce"): + pierce_rows += 1 + Kit.check("enc 两轴直达 spawn 英雄快照(hero_attack 行携带 pierce 键——注入链末端)〔锻造工坊批 V5〕", + pierce_rows >= 1) + # spawn 显式注入面(三参旧形态零注入已在上方覆盖——此处钉显式值) + var uc2 = load("res://core/unit_container.gd").new(_db) + Kit.check("spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕", + uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy") + and String(uc2.hero_unit().get("attack_type", "")) == "magic" + and String(uc2.hero_unit().get("armor_type", "")) == "heavy") + # V10 复核:树表 12 行 effects 键集无 atk/armor_class(契约 1——树零攻防来源) + var tree_atk_ac := false + for trow in _db.tree_rows(): + var eff: Dictionary = trow.get("effects", {}) + if eff.has("atk") or eff.has("armor_class"): + tree_atk_ac = true + Kit.check("树零攻防来源复核(12 行 effects 键集无 atk/armor_class——契约 1)〔锻造工坊批 V10〕", + not tree_atk_ac) + + +# ---- 天赋树冒烟(v0.8 12 节点:schema/购买面/Fork 一节点双态/彩蛋特化)---- + +func _smoke_tree() -> void: + print("--- 天赋树(12 节点 · 购买面 · Fork 双态 · 彩蛋觉醒)---") + # 〔V1〕结构:12 行 + schema 零违例 + Kit.check("meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕", + _db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12) + Kit.check("MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)", + _db.meta_tree.validate().is_empty()) + # 彩蛋行硬要求(契约 9 红线落点):effects 键集 ⊆ {max_hp, hit} + var ley: Dictionary = _db.meta_tree.row_by_id("easter_ley") + var ley_ok: bool = not ley.is_empty() + for k in (ley.get("effects", {}) as Dictionary).keys(): + if not (String(k) == "max_hp" or String(k) == "hit"): + ley_ok = false + Kit.check("easter_ley effects 键集 ⊆ {max_hp, hit}(攻/护分量已摘除——契约 9)〔天赋树批 V8 落点〕", + ley_ok) + # V1/V4 反例:出域行逐类构造被拒(复制行副本塞词表外值) + var t2 = load("res://data/schemas/meta_tree_table.gd").new() + var bad_vein: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + bad_vein["node_id"] = "bad_vein" + bad_vein["vein"] = "underworld" + t2.rows = [bad_vein] + Kit.check("schema 反例:vein 出域被拒", not t2.validate().is_empty()) + var bad_fork: Dictionary = (_db.meta_tree.row_by_id("fork_posture") as Dictionary).duplicate(true) + bad_fork["node_id"] = "bad_fork" + bad_fork["fork_group"] = "fg_mastery" + t2.rows = [bad_fork] + Kit.check("schema 反例:fork_group 出域(fg_mastery 已退役组)被拒", not t2.validate().is_empty()) + var bad_arch: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + bad_arch["node_id"] = "bad_arch" + bad_arch["effects"] = {"archetype": 1} + t2.rows = [bad_arch] + Kit.check("schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕", + not t2.validate().is_empty()) + var bad_dangling: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + bad_dangling["requires"] = ["ghost_node"] + t2.rows = [bad_dangling] + Kit.check("schema 反例:requires 悬空被拒", not t2.validate().is_empty()) + # 购买面(V2 主干) + var sv = _meta_new_save(_META_SMOKE_PATHS[8]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + sv.add_keystone(1000) + Kit.check("树购买受理(脊柱生命 L1——树全钥石单币)", + mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1) + Kit.check("行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕", + not mp.tree_can_buy("sp_hp")) + Kit.check("命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)", + mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1")) + Kit.check("锻造工坊钥匙零币(free)", int(_db.tree_node("forge_workshop").get("price", -1)) == 0) + Kit.check("行门槛恰达开放(总等级 4 ≥ 4×1)", mp.tree_can_buy("sp_hp")) + Kit.check("第 2 级购买受理(门槛恰达当刻开放)", + mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2) + # Fork 一节点双态(V9) + sv.add_keystone(100) + Kit.check("Fork 购买=解锁+默认 A 态(一节点双态——单行承载)", + mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a") + Kit.check("已解锁 Fork 重复购买拒绝(cap 1)", not mp.tree_can_buy("fork_posture")) + Kit.check("同态重复切换零操作(false)", not mp.tree_toggle_fork("fg_posture", "a")) + Kit.check("未解锁 Fork 切换拒绝(解锁唯一入口=购买面)", not mp.tree_toggle_fork("fg_sense", "b")) + var bal_pre_toggle: int = sv.keystone_balance() + Kit.check("零币切态 b(购买解锁后 A/B 可切)", + mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b") + Kit.check("切态零消费(钥石余额差分不动)", int(sv.keystone_balance()) == bal_pre_toggle) + Kit.check("规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)", + (mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike")) + Kit.check("切回 A 态键集随换(六键词表在册——V11 校验面归单元段)", + mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike")) + # 彩蛋特化觉醒(V8) + Kit.check("彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)", not mp.tree_can_buy("easter_ley")) + Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.tree_level("easter_ley") == 0) + sv.set_tree_level("sp_hp", 4) + sv.set_tree_level("sp_hit", 4) + sv.set_tree_level("att_socket2", 1) + mp.tree_buy("att_socket3") # 经购买口触发 _check_easter_egg + Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.tree_level("easter_ley") == 1) + var bal_now: int = sv.keystone_balance() + mp.tree_buy("sp_hp") + Kit.check("觉醒零消费(钥石余额不动)", int(sv.keystone_balance()) == bal_now) + _meta_smoke_cleanup() + + +# ---- 天赋树六键行为冒烟(V10:开局姿态窗/危险直觉限次/财富之道聚散/守侧失误补偿; +# 引擎直构 + 编排授予段直注(_grant_seg._judge_seal=结算授予段封印判定单口))---- +func _smoke_tree_rules() -> void: + print("--- 天赋树六键行为(V10:窗/限次/聚散/守侧)---") + # 开局姿态组:每关首轮窗(battle_index 事实——引擎直构 + 自建 ctx) + var fx = load("res://core/effect_engine.gd").new(null) + fx.rebuild([], [], ["posture_first_strike", "posture_bulwark"]) + var ctx_p := EffectContext.new() + Kit.check("先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕", + (fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1) + Kit.check("先手压制:首轮窗外地不触发(第二轮/第二关首战首轮——「每战首轮」旧语义换代件)", + (fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 2}, Callable()) as Array).is_empty() + and (fx.dispatch(ctx_p, "round_start", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty()) + Kit.check("稳健拓荒:首轮窗内受击出盾、窗外不出(shield damage_taken 同窗条件)", + (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1 + and (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty()) + # 危险直觉·锋:限次闸(引擎直构——mine_hit 挂点现成;限次计数住 level 桶,跨战持久=共享同一字典引用) + var fz = load("res://core/effect_engine.gd").new(null) + fz.rebuild([], [], ["sense_sharp"]) + var lvl_shared := {} # 同一关的 level 桶(两编排器共享引用 = 跨战持久,替代旧副本搬运) + var ctx_fz := EffectContext.new() + ctx_fz.level_bucket = lvl_shared + var got_mult := false + var got_add := false + for h in (fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array): + if String(h.get("kind", "")) == "damage_mult": + got_mult = true + if String(h.get("kind", "")) == "counter_add": + got_add = true + Kit.check("锋:踩雷减半命中+限次记账(counter 0<2 闸开——damage_mult+counter_add 双行同预评估态)", + got_mult and got_add) + EffectEngine.counter_add(ctx_fz, "lvl_tree_sense_sharp_used", 2) # 直调置数(V10 注入口径——置 level 桶计数后走事件路径) + Kit.check("锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)〔无状态化 V5〕", + (fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array).is_empty()) + var fz2 = load("res://core/effect_engine.gd").new(null) + fz2.rebuild([], [], ["sense_sharp"]) + var ctx_fz2 := EffectContext.new() + ctx_fz2.level_bucket = lvl_shared # 挂同一 level 桶引用(新形态跨战持久:无 export/seed,同字典即持久) + Kit.check("锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕", + (fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty()) + # 守侧五场景(契约 12):编排 rig——begin_battle 携 sense_guard,授予段 _grant_seg._judge_seal 直注 + var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777001)) + Kit.check("守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)", + orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, + "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) + var board = orch.minesweeper.current_board() + var safe := Vector2i(-1, -1) + for c in board.all_cells(): + if not board.is_mine(c): + safe = c + break + Kit.check("守侧 rig:安全格定位(非危险格=标错负样本)", safe.x >= 0) + Kit.check("守侧 rig:首轮后 queue 干净(开局 posture queue 已兑现)", + int(orch.action_economy.pending_ap_delta()) == 0) + # 场景①:当回合新封印、结算判标错且封印仍在场 → queue +1(本轮 AP 零变化=queue 承载、下轮 notify_round_start 兑现) + orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 1) + Kit.check("守①:当回合标错且封印在场 → 下轮 AP+1(queue 排队——本轮 AP 逐位零变化)", + int(orch.action_economy.pending_ap_delta()) == 1) + # 场景③④:之前回合标错重判(封印在场状态)与同格重复——去重闸挡回,最多返还一次 + orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 2) + orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 3) + Kit.check("守③④:旧封印重判零触发+同格最多一次(_seal_miss_seen 去重闸=「当回合标错动作」判别器)", + int(orch.action_economy.pending_ap_delta()) == 1) + # 场景②负样本:封印命中(hit=true 正向兑付路径)零触发 + orch._grant_seg._judge_seal({"cell": safe, "hit": true}, 4) + Kit.check("守②负样本:标中/被挖封印零触发(标错事件只在 miss 分支发射——发射面过滤承载)", + int(orch.action_economy.pending_ap_delta()) == 1) + # 场景⑤:末轮判出→无下一轮=queue 不兑现(编排器每战新建——队列实例随战亡,合法作废态) + var orch2 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777002)) + Kit.check("守⑤ rig:次战编排启动(新战新队列实例)", + orch2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, + "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) + Kit.check("守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)", + int(orch2.action_economy.pending_ap_delta()) == 0) + # 树限次按关清零改由 LevelRunner.level_counters 随 new 承载(旧硬编码名单冒烟断言随函数删除——换代同生死;新语义由 V4/V5 段承载) + # 财富之道·散:商品标价折价(level_runner 计价单点) + var lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777004)) + lr.set_tree_rules([]) + Kit.check("散未激活:商品标价原价(无 economy_spend 键)", int(lr._spend_discounted(100)) == 100) + lr.set_tree_rules(["economy_spend"]) + Kit.check("散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)", + int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1) + # 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×) + var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) + Kit.check("聚 rig:基线编排启动(无增益侧)", + orch3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": [], "tree_rules": [], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, + "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) + orch3.encounter["tree_rules"] = [] + var gp_base: int = orch3._roll_gp_drop() + var orch4 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) + Kit.check("聚 rig:增益编排启动(tree_rules=economy_hoard)", + orch4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": [], "tree_rules": ["economy_hoard"], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, + "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) + orch4.encounter["tree_rules"] = ["economy_hoard"] + var gp_hoard: int = orch4._roll_gp_drop() + Kit.check("聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)", + gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base) + _meta_smoke_cleanup() diff --git a/game/tests/unit/core/save_boot_no_activate_test.gd b/game/tests/unit/core/save_boot_no_activate_test.gd index 19afb2d4..27674ed7 100644 --- a/game/tests/unit/core/save_boot_no_activate_test.gd +++ b/game/tests/unit/core/save_boot_no_activate_test.gd @@ -4,7 +4,7 @@ # 生产链无激活触达写盘被拒、访问器 fail-safe 合法空值、越界槽号拒绝。 # 真实 autoload boot 链断言在冒烟 V2-s1..s4 段——--script 模式无 autoload,本套件为类级 # 白盒面+生产链对照,双落点在实现报告实际对照表对账。) -# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +# 载体:run_all 单元测试(显式实例化夹具——--script 模式无 autoload)。 extends SceneTree const SaveManagerScript := preload("res://core/save_manager.gd") diff --git a/game/tests/unit/core/save_manager_domain_test.gd b/game/tests/unit/core/save_manager_domain_test.gd new file mode 100644 index 00000000..b24e5847 --- /dev/null +++ b/game/tests/unit/core/save_manager_domain_test.gd @@ -0,0 +1,504 @@ +# tests/unit/core/save_manager_domain_test.gd — 存档域单元测试宿主 +# 断言面 = 两套(批 B 断言迁移自 smoke_all.gd,G10 施工组;两套按源编排序迁入): +# SaveManager 存档读写/迁移/兜底(读写往返/新档合法/余额拒花/高版本拒读禁写/损坏兜底/ +# 迁移链/域界干净/摘要 cap/断点域七字段/旧档迁移族/收束链入账) +# SaveManager 三存档位承载(三槽往返/位间隔离/删档/新档白板/高版本拒读恢复/窥视三态/旧单档迁移) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入(v2 断点续玩受理与收束链夹具消费 _db);表加载失败不中止 +# (沿被迁断言面的源语义——诊断在此展开,红由具体断言落,承 G2–G5 同款)。 +# 测试文件统一 user://smoke_save_*.json 隔离,段首清场+段尾清理——绝不碰 user://save.json(红线)。 +# 分宿主注记:源 `_smoke_save` ⑨ 摘要集成块(level_autoplay 真局驱动×落盘,基点 :6807–6831) +# 经主会话裁决三改判集成测试——不随本宿主,见迁移账本转办清单。 +# 运行:godot --headless --path game --script res://tests/unit/core/save_manager_domain_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2–G5 同款:表损坏的红落在具体断言上) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[save_manager_domain_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排序 + _smoke_save() + _smoke_save_slots() + quit(Kit.finish("save_manager_domain_test")) + + +# ---- 隔离夹具(smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)---- + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# ---- 存档序列化冒烟(SaveManager——读写往返/新档合法/余额拒花/锻造出域拒/高版本拒读禁写/ +# 损坏兜底/迁移链/域界干净/摘要 cap/摘要集成/生涯终身计数域 v6)。测试文件统一 user://smoke_save_*.json 隔离, +# 段首清场+段尾清理——绝不碰 user://save.json(红线)。 +func _smoke_save() -> void: + print("--- SaveManager 存档读写/迁移/兜底 ---") + var sm_script = load("res://core/save_manager.gd") + var paths := ["user://smoke_save_roundtrip.json", "user://smoke_save_fresh.json", + "user://smoke_save_spend.json", "user://smoke_save_highver.json", + "user://smoke_save_corrupt.json", "user://smoke_save_migrate.json", + "user://smoke_save_domain.json", "user://smoke_save_cap.json", + "user://smoke_save_integration.json", "user://smoke_save_breakpoint.json", + "user://smoke_save_v2compat.json", "user://smoke_save_v5upgrade.json", + "user://smoke_save_v5legacy.json", "user://smoke_save_v16discard.json", + "user://smoke_save_forge_v6legacy.json"] + var stale: Array = [] + for p in paths: + stale.append(p) + stale.append(p + ".tmp") + stale.append(p + ".corrupt.bak") + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + # ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等 + var w = sm_script.new(paths[0]) + w.add_keystone(7) + w.set_tree_level("sp_hp", 2) + w.set_tree_level("sp_hit", 1) + w.set_fork_side("fg_posture", "b") # 树域写入 + w.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记) + w.add_forge_unlock("spear") + w.set_forge_weapon("spear") + w.set_forge_armor("ward") + w.unlock_item("probe_compass") + w.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2, + "stopped_at": 3, "keystone_total": 3, "master_seed": 99}) + Kit.check("apply 原语入账(钥石 7+3、通关次数 1/1/0)", + w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0) + Kit.check("首杀标记随通关关卡置位(1/2 有、3 无)", + w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3)) + Kit.check("生涯终身计数随 apply 自增恰一(中止局:总局数 1、通关局数 0——迭代 18 卡④)", + w.adventure_total() == 1 and w.adventure_cleared() == 0) + Kit.check("写盘成功", w.write_to_disk()) + var r = sm_script.new(paths[0]) + Kit.check("读回成功", r.load_from_disk()) + Kit.check("钥石余额往返相等(10)", r.keystone_balance() == 10) + Kit.check("树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)", + r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b") + Kit.check("锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕", + r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0 + and r.forge_unlocked_purchases() == ["spear"] + and r.forge_weapon() == "spear" and r.forge_armor() == "ward") + Kit.check("道具解锁往返相等", r.item_unlocked("probe_compass") and not r.item_unlocked("rope")) + Kit.check("通关次数表往返相等", r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0) + Kit.check("首杀标记往返相等", + r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3)) + Kit.check("生涯终身计数往返相等(1/0)", r.adventure_total() == 1 and r.adventure_cleared() == 0) + var s0: Dictionary = r.recent_summaries()[0] + Kit.check("冒险摘要往返相等(1 条,seed 99)", + r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) + + # ② 新档合法:不存在路径 load → true + 全零默认值 + var f2 = sm_script.new(paths[1]) + Kit.check("新档合法态(load true + 全零默认)", + f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0 + and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty()) + Kit.check("新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)", + f2.adventure_total() == 0 and f2.adventure_cleared() == 0) + + # ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外) + var f3 = sm_script.new(paths[2]) + f3.add_keystone(5) + Kit.check("余额 5 花 8 拒绝且余额不动", not f3.spend_keystone(8) and f3.keystone_balance() == 5) + Kit.check("余额内消费成功(5→2)", f3.spend_keystone(3) and f3.keystone_balance() == 2) + + # ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4) + var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}" + var fhv := FileAccess.open(paths[3], FileAccess.WRITE) + fhv.store_string(hv_text) + fhv.close() + var f5 = sm_script.new(paths[3]) + Kit.check("高版本拒读(load false)", not f5.load_from_disk()) + Kit.check("拒读后原文件字节不动", FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text) + Kit.check("拒读态写盘拒绝且文件仍字节不动", + not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text) + + # ⑥ 损坏兜底:非 JSON 字节 → 默认数据 + .corrupt.bak(不静默丢档) + var junk := "not json {{{" + var fj := FileAccess.open(paths[4], FileAccess.WRITE) + fj.store_string(junk) + fj.close() + var f6 = sm_script.new(paths[4]) + Kit.check("损坏档兜底(默认数据继续)", + f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty()) + Kit.check("坏档备份 .corrupt.bak 存在", FileAccess.file_exists(paths[4] + ".corrupt.bak")) + Kit.check("备份内容 = 原坏档字节", + FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk) + + # ⑦ 迁移链:register_migration 测试钩 → 手工 v0 旧结构档 → 链式升级 v1 + var mig := func(d: Dictionary) -> Dictionary: + return {"version": 1, "meta": {"keystone_balance": int(d.get("keystone_old", 0))}} + var f7 = sm_script.new(paths[5]) + f7.register_migration(0, mig) + var fm := FileAccess.open(paths[5], FileAccess.WRITE) + fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12})) + fm.close() + Kit.check("v0 旧档经迁移链入账(keystone_old 12 → 余额 12)", + f7.load_from_disk() and f7.keystone_balance() == 12) + Kit.check("迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)", + f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7) + + # ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2; + # 扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 属豁免面) + var disk_parsed = JSON.parse_string(FileAccess.open(paths[0], FileAccess.READ).get_as_text()) + var meta_text := JSON.stringify((disk_parsed as Dictionary).get("meta", {})) + var banned := ["\"ap\"", "\"rap\"", "\"gp\"", "\"inventory\"", "\"hero_hp\""] + var leaked := [] + for b in banned: + if meta_text.contains(b): + leaked.append(b) + Kit.check("域界干净——meta 域无局内资源键(发现 %s)" % str(leaked), leaked.is_empty()) + + # ⑩ 摘要 cap:12 份 → 保留 10、最新在前 + var f10 = sm_script.new(paths[7]) + for i in range(12): + f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0, + "stopped_at": 0, "keystone_total": 0, "master_seed": i}) + var arr: Array = f10.recent_summaries() + Kit.check("摘要 cap 10(12 份 → 10 条)", arr.size() == 10) + Kit.check("最新在前(头 seed 11 / 尾 seed 2)", + int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2) + + # ⑨ 摘要集成块(level_autoplay 真局驱动×落盘,基点 :6807–6831)——经主会话裁决三 + # 改判集成测试,不随本宿主(迁移账本转办清单)。 + + # ⑪ 断点域:七字段写读往返逐位一致+附随件+值拷贝出户+清除+旧档迁移合法空态 + var fbp = sm_script.new(paths[9]) + Kit.check("断点:新档无断点(合法空态——「继续上局」隐藏)", not fbp.has_breakpoint()) + fbp.set_breakpoint({ + "level_index": 2, "node_id": 5, "hero_hp": 13, + "bonus": {"atk": 2, "max_hp": 5, "armor_class": 1}, + "gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1}, + "template_pick": 3, + "master_seed": 424242, "rng_counter": 38, "keystone": 6, + "visited_ids": [0, 2, 5], + "aux_items": [{"item_id": "aux_scout", "source_class": "reward_node", + "origin_cell": [3, 4], "round_index": 2, "used": false}], + }) + Kit.check("断点:写入+写盘成功", fbp.has_breakpoint() and fbp.write_to_disk()) + var rb = sm_script.new(paths[9]) + Kit.check("断点:读回在档", rb.load_from_disk() and rb.has_breakpoint()) + var bp: Dictionary = rb.breakpoint_snapshot() + Kit.check("断点七字段往返:关卡序号/节点位/生命", + int(bp.get("level_index", -1)) == 2 and int(bp.get("node_id", -1)) == 5 + and int(bp.get("hero_hp", -1)) == 13) + var bp_bonus: Dictionary = bp.get("bonus", {}) + Kit.check("断点七字段往返:属性加成区", + int(bp_bonus.get("atk", -1)) == 2 and int(bp_bonus.get("max_hp", -1)) == 5 + and int(bp_bonus.get("armor_class", -1)) == 1) + Kit.check("断点七字段往返:GP 钱包", int(bp.get("gp", -1)) == 17) + var bp_shop: Dictionary = bp.get("shop_purchased", {}) + Kit.check("断点七字段往返:商店已购档", + int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1) + Kit.check("断点七字段往返:已抽模板", int(bp.get("template_pick", -1)) == 3) + Kit.check("断点附随件往返:主种子/计数器/钥石累计", + int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38 + and int(bp.get("keystone", -1)) == 6) + var bp_visited: Array = bp.get("visited_ids", []) + Kit.check("断点附随件往返:已到节点序", + bp_visited.size() == 3 and int(bp_visited[0]) == 0 and int(bp_visited[2]) == 5) + var bp_treasure: Array = bp.get("aux_items", []) + Kit.check("断点附随件往返:道具库存", + bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout") + bp["hero_hp"] = 999 + Kit.check("断点读面值拷贝(改读面不渗档)", + int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13) + rb.clear_breakpoint() + Kit.check("断点:清除+写盘(三结局收束口径)", not rb.has_breakpoint() and rb.write_to_disk()) + var rc = sm_script.new(paths[9]) + Kit.check("断点:清除后读回无断点", rc.load_from_disk() and not rc.has_breakpoint()) + var fo = sm_script.new(paths[9]) + var fold := FileAccess.open(paths[9], FileAccess.WRITE) + fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}") + fold.close() + Kit.check("v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)", + fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint()) + Kit.check("v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)", + fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7) + + # ⑫ v2 旧档→v3 兼容(semver 向后兼容:旧断点零丢失、新代码可续玩) + var fv2 = sm_script.new(paths[10]) + var fv2f := FileAccess.open(paths[10], FileAccess.WRITE) + fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7}, + "breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5, + "bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {}, + "template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0, + "visited_ids": []}})) + fv2f.close() + Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)", + fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7) + Kit.check("v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)", + int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777) + var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777)) + Kit.check("v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)", + adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}})) + + # ⑬ 生涯终身计数域:v5 旧档升 v6 缺省补零 + 升档幂等 + 收束自增恰一 + #(真实旧档形态=计数域两键不存在;升档只增不改——既有 meta 值零触碰,存档红线 S2 断言面) + var f13 = sm_script.new(paths[11]) + var fv5u := FileAccess.open(paths[11], FileAccess.WRITE) + fv5u.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 9}})) + fv5u.close() + Kit.check("v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)", + f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 + and f13.keystone_balance() == 9) + Kit.check("升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)", + f13.write_to_disk() and f13.load_from_disk() + and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7 + and f13.adventure_total() == 0 and f13.adventure_cleared() == 0) + var f13b = sm_script.new(paths[12]) + f13b.load_from_disk() + f13b.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + "stopped_at": 0, "keystone_total": 0, "master_seed": 1}) + Kit.check("收束自增恰一·通关局(三关全通 cleared=true:总局数+1 且通关局数+1)", + f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1) + f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 0, "master_seed": 2}) + Kit.check("收束自增恰一·中止局(放弃局:仅总局数+1,通关局数不动)", + f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1) + + # ⑮ 旧档破坏性废弃(契约 4:换代前结构检测=树域废弃重置,禁迁移禁折算)〔V16〕 + var f15 = sm_script.new(paths[13]) + var fv16 := FileAccess.open(paths[13], FileAccess.WRITE) + fv16.store_string(JSON.stringify({"version": 6, "meta": {"keystone_balance": 33, + "forge_levels": {"max_hp": 3, "atk": 4, "armor_class": 2}, + "tree_nodes": {"sp_hp_r1": 3, "forge_atk": 2, "fork_posture_a": 1}, + "fork_sides": {"fg_posture": "a", "fg_mastery": "b"}}})) + fv16.close() + Kit.check("换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动", + f15.load_from_disk() and f15.keystone_balance() == 33 + and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty()) + var mp16 = load("res://core/meta_progression.gd").new(_db, f15) + f15.add_keystone(50) + Kit.check("废弃重置后后续购买正常受理(无残留脏节点)", + mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1) + + # ⑯ 锻造工坊域 forge_routes(V8):v6 旧档缺键补齐=初始持有态+无特效域+域界干净 + var f16 = sm_script.new(paths[14]) + var fv6 := FileAccess.open(paths[14], FileAccess.WRITE) + fv6.store_string(JSON.stringify({"version": 6, "meta": {"keystone_balance": 21, + "tree_nodes": {"sp_hp": 1}, "tree_struct": 2, "adventure_total": 4, "adventure_cleared": 1}})) + fv6.close() + Kit.check("v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕", + f16.load_from_disk() and f16.keystone_balance() == 21 + and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1) + Kit.check("v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕", + f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 + and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 + and f16.forge_unlocked_purchases().is_empty() + and f16.forge_weapon() == "sword" and f16.forge_armor() == "light") + var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys() + fr_keys.sort() + Kit.check("forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕", + fr_keys == ["armor", "levels", "unlocked", "weapon"]) + var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys() + lv_keys.sort() + Kit.check("levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕", + lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"]) + # 值拷贝出户(改快照不渗档——本类契约) + var snap: Dictionary = f16.forge_routes_snapshot() + (snap.get("levels", {}) as Dictionary)["sword"] = 99 + Kit.check("forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕", + f16.forge_route_level("sword") == 0) + + # ⑭ 收束链入账(真实收束挂点全链恰一:主流程 adventure_finished → + # MetaProgression.settle_adventure → apply_adventure_summary 单口;两夹具经同链入账) + var mp13 = load("res://core/meta_progression.gd").new(_db, f13b) + mp13.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2, + "stopped_at": 3, "keystone_total": 0, "master_seed": 3}) + Kit.check("收束链自增·中止局(settle_adventure 真实挂点:总局数+1、通关局数不动)", + f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1) + mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3, + "stopped_at": 0, "keystone_total": 0, "master_seed": 4}) + Kit.check("收束链自增·通关局(三关全通:总局数+1 且通关局数+1——全链恰一)", + f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2) + + # 段尾清理(测试文件绝不残留) + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + +# ---- 三存档位承载(迭代 33 卡①:三槽往返/新档/进档/删档/窥视三态/各槽迁移/旧单档迁移)---- +# 测试文件统一 user://smoke_save_slot_%d.json 模板隔离(attach_slot_template 注入口——禁各自拼槽名), +# 段首清场+段尾清理——绝不碰 user://save.json 与真实槽 user://save_slot_N.json(红线)。 +func _smoke_save_slots() -> void: + print("--- SaveManager 三存档位承载(迭代 33 卡①)---") + var sm_script = load("res://core/save_manager.gd") + var slot_tpl := "user://smoke_save_slot_%d.json" + var legacy_src := "user://smoke_save_legacy_src.json" + var stale: Array = [] + for i in range(1, 4): + for suf in ["", ".tmp", ".corrupt.bak"]: + stale.append(slot_tpl % i + suf) + stale.append_array([legacy_src, legacy_src + ".migrated.bak"]) + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + var t = sm_script.new() + t.attach_slot_template(slot_tpl) + + # ① 三槽往返×3+位间隔离+进档(sv1/sv5/sv10) + for i in range(1, 4): + Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.activate_slot(i)) + t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + "stopped_at": 0, "keystone_total": 100 * i, "master_seed": i}) + Kit.check("sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i, t.write_to_disk()) + var t2 = sm_script.new() + t2.attach_slot_template(slot_tpl) + for i in range(1, 4): + Kit.check("sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i, + t2.activate_slot(i) and t2.keystone_balance() == 100 * i + and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0)) + var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) + t.activate_slot(1) + t.add_keystone(7) + Kit.check("sv3 写盘受理·槽 1 加值后〔卡① sv1〕", t.write_to_disk()) + Kit.check("sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕", + FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before) + + # ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动) + Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.activate_slot(3)) + Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.delete_slot(3)) + Kit.check("sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕", + not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp")) + Kit.check("sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕", + t.active_slot() == 0 and not t.has_write_target()) + var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1) + t.activate_slot(1) + Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.delete_slot(2)) + Kit.check("sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕", + not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1) + Kit.check("sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕", + FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before) + + # ③ 新档原语(sv4):非白板旧内容覆盖为全零可玩白板(判别锚:新档禁读旧档——变异可红), + # 创建即激活并落盘(生产路径只在空槽调用;本段预置旧内容是对原语白板保证的钉死,超生产面) + var fold := FileAccess.open(slot_tpl % 3, FileAccess.WRITE) + fold.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 999, + "adventure_total": 5, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) + fold.close() + Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.create_slot(3)) + Kit.check("sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕", + t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() + and t.adventure_total() == 0) + var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3)) + Kit.check("sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕", + int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0) + + # ④ 高版本拒读槽按槽成立+激活失败恢复切换前三态(sv9/sv10) + var fhv := FileAccess.open(slot_tpl % 2, FileAccess.WRITE) + fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) + fhv.close() + var hv = sm_script.new() + hv.attach_slot_template(slot_tpl) + Kit.check("sv5 进档槽 1 读回(107)〔卡① sv10〕", + hv.activate_slot(1) and hv.keystone_balance() == 107) + var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2) + Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.activate_slot(2)) + Kit.check("sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕", + hv.active_slot() == 1 and hv.keystone_balance() == 107) + hv.add_keystone(1) + Kit.check("sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕", + hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv) + + # ⑤ 迁移链各槽成立(sv9):三槽各喂 v5 旧档 → 各自激活读回迁移值 + for i in range(1, 4): + var fm := FileAccess.open(slot_tpl % i, FileAccess.WRITE) + fm.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": i * 11}})) + fm.close() + for i in range(1, 4): + var mi = sm_script.new() + mi.attach_slot_template(slot_tpl) + Kit.check("sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i, + mi.activate_slot(i) and mi.keystone_balance() == i * 11 + and mi.write_to_disk()) + + # ⑥ 窥视三态零副作用(sv7/sv8):异值三槽逐槽三项+空槽零值+坏/高版本字节 mtime 双比对 + var pv = sm_script.new() + pv.attach_slot_template(slot_tpl) + for i in range(1, 4): + var fp := FileAccess.open(slot_tpl % i, FileAccess.WRITE) + fp.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 200 * i, + "adventure_total": 5 * i, "adventure_cleared": i, + "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) + fp.close() + for i in range(1, 4): + var ps: Dictionary = pv.peek_slot_summary(i) + Kit.check("sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" % i, + bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i + and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i) + Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.delete_slot(3)) + var pe: Dictionary = pv.peek_slot_summary(3) + Kit.check("sv7 空槽窥视=零值合法态〔卡① sv7〕", + bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 + and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0) + var fbad := FileAccess.open(slot_tpl % 1, FileAccess.WRITE) + fbad.store_string("not json{{{") + fbad.close() + var bad_bytes := FileAccess.get_file_as_bytes(slot_tpl % 1) + var bad_mtime := FileAccess.get_modified_time(slot_tpl % 1) + var pb: Dictionary = pv.peek_slot_summary(1) + Kit.check("sv8 坏槽窥视=显式损坏标记〔卡① sv8〕", not bool(pb.get("readable", true))) + Kit.check("sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕", + FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes + and FileAccess.get_modified_time(slot_tpl % 1) == bad_mtime + and not FileAccess.file_exists(slot_tpl % 1 + ".corrupt.bak")) + var fhv2 := FileAccess.open(slot_tpl % 2, FileAccess.WRITE) + fhv2.store_string(JSON.stringify({"version": 99, "meta": {}})) + fhv2.close() + var hv_bytes := FileAccess.get_file_as_bytes(slot_tpl % 2) + var hv_mtime := FileAccess.get_modified_time(slot_tpl % 2) + var ph: Dictionary = pv.peek_slot_summary(2) + Kit.check("sv8 高版本槽窥视=不可读标记〔卡① sv8〕", not bool(ph.get("readable", true))) + Kit.check("sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕", + FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes + and FileAccess.get_modified_time(slot_tpl % 2) == hv_mtime) + + # ⑦ 旧单档迁移(sv12):字节搬运+源改名留痕+幂等+他槽零触碰 + var ml = sm_script.new() + ml.attach_slot_template(slot_tpl) + var fsrc := FileAccess.open(legacy_src, FileAccess.WRITE) + fsrc.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 1234}})) + fsrc.close() + DirAccess.remove_absolute(slot_tpl % 1) + ml._migrate_legacy_save(legacy_src, slot_tpl % 1, legacy_src + ".migrated.bak") + var migrated: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 1)) + Kit.check("sv12 旧档字节迁入槽 1(逐域相等——余额 1234)〔卡① sv12〕", + int((migrated.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234) + Kit.check("sv12 迁移后源改名留痕(.migrated.bak 在盘、原路径不在)〔卡① sv12〕", + not FileAccess.file_exists(legacy_src) and FileAccess.file_exists(legacy_src + ".migrated.bak")) + var s2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) + var s3_before := FileAccess.get_file_as_bytes(slot_tpl % 3) + var fsrc2 := FileAccess.open(legacy_src, FileAccess.WRITE) + fsrc2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 777}})) + fsrc2.close() + ml._migrate_legacy_save(legacy_src, slot_tpl % 1, legacy_src + ".migrated.bak") + var slot1_now: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 1)) + Kit.check("sv12 幂等:槽 1 已存在不重迁(预置 777 零覆盖、源保留)〔卡① sv12〕", + int((slot1_now.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 + and FileAccess.file_exists(legacy_src)) + Kit.check("sv12 迁移他槽零触碰(槽 2/3 字节不动)〔卡① sv12〕", + FileAccess.get_file_as_bytes(slot_tpl % 2) == s2_before + and FileAccess.get_file_as_bytes(slot_tpl % 3) == s3_before) + + # 段尾清理(测试文件绝不残留) + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) diff --git a/game/tests/unit/core/save_slot_isolation_test.gd b/game/tests/unit/core/save_slot_isolation_test.gd index ca49a91f..20cb7260 100644 --- a/game/tests/unit/core/save_slot_isolation_test.gd +++ b/game/tests/unit/core/save_slot_isolation_test.gd @@ -2,7 +2,7 @@ # (三槽往返异值逐域读回、改 A 槽 B 槽字节不动、高版本槽进档失败恢复切换前三态、 # MetaProgression 宿主链切槽前后引用同一且写盘落当前槽、attach_slot_template 注入语义。 # 测试隔离:槽模板注入 user://smoke_slotiso_%d.json 命名空间——绝不碰真实槽与 user://save.json。) -# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +# 载体:run_all 单元测试(显式实例化夹具——--script 模式无 autoload)。 extends SceneTree const SaveManagerScript := preload("res://core/save_manager.gd") diff --git a/game/tests/unit/core/save_slot_summary_test.gd b/game/tests/unit/core/save_slot_summary_test.gd index 4d83a816..ba9592e7 100644 --- a/game/tests/unit/core/save_slot_summary_test.gd +++ b/game/tests/unit/core/save_slot_summary_test.gd @@ -2,7 +2,7 @@ # (三槽异值逐槽三项正确、空槽零值合法态、坏槽/高版本/缺版本字段=显式损坏标记、 # 窥视零副作用(字节+mtime 双比对+零 .corrupt.bak 改名+不激活不写内存)、路径注入口 peek_summary_at。 # 测试隔离:模板注入 user://smoke_slotsum_%d.json 命名空间——绝不碰真实槽与 user://save.json。) -# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +# 载体:run_all 单元测试(显式实例化夹具——--script 模式无 autoload)。 extends SceneTree const SaveManagerScript := preload("res://core/save_manager.gd") diff --git a/game/tests/unit/core/tree_requires_level_test.gd b/game/tests/unit/core/tree_requires_level_test.gd index 516073d2..46d143b5 100644 --- a/game/tests/unit/core/tree_requires_level_test.gd +++ b/game/tests/unit/core/tree_requires_level_test.gd @@ -2,7 +2,7 @@ # (schema requires_level Dictionary{node_id: n} + validate 悬空/阈值 <1/类型错即拒 + # tree_can_buy 等级阈值判定 + 行板 gate_text 门槛文案投影。 # 现役 meta_tree.tres 12 行零配置=机制位空转,判定面经本夹具直构行承载。) -# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +# 载体:run_all 单元测试(显式实例化夹具——--script 模式无 autoload)。 extends SceneTree diff --git a/game/tests/unit/core/tree_rules_v11_test.gd b/game/tests/unit/core/tree_rules_v11_test.gd index cdda06cf..ad233a8b 100644 --- a/game/tests/unit/core/tree_rules_v11_test.gd +++ b/game/tests/unit/core/tree_rules_v11_test.gd @@ -7,7 +7,7 @@ # rebuild 树规则段对两键零合成行——此状态在此被显式断言(旧版对两键取空夹具静默跳过=假绿): # rebuild 实装面行数对照——四键逐键与夹具同行数(rebuild 分支被删/漂移即转红), # 两键断零行(将来被加合成行即转红,提示先复核效果闭集再同步夹具与实现)。 -# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。) +# 载体:run_all 单元测试(显式实例化夹具——--script 模式无 autoload)。) extends SceneTree diff --git a/game/tests/unit/data_config/rng_stream_boot_test.gd b/game/tests/unit/data_config/rng_stream_boot_test.gd new file mode 100644 index 00000000..f3747bde --- /dev/null +++ b/game/tests/unit/data_config/rng_stream_boot_test.gd @@ -0,0 +1,53 @@ +# tests/unit/data_config/rng_stream_boot_test.gd — 数据配置加载链与统一 RNG 流单元测试宿主 +# 断言面 = 启动初始化套(批 B 断言迁移自 smoke_all.gd `_initialize`,G12 施工组): +# data/tables 加载链(全部表加载 + 跨表护栏) +# core/rng_service.gd 统一流(master 种子 42 黄金向量 / derive_at 纯函数恒等 / +# open_stream 取号序 / new_adventure 会话计数器重置) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB/RngService 显式实例化构造注入(master 种子 42 沿源); +# 表加载失败不中止(沿被迁断言面的源语义——诊断在此展开,加载链的红由具体断言落)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/rng_stream_boot_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + + +func _init() -> void: + _smoke_initialize() + quit(Kit.finish("rng_stream_boot_test")) + + +# ---- 启动初始化断言面(宿主接线注记:源函数名 `_initialize` 是 MainLoop 虚拟入口, +# 单元宿主改由 `_init` 显式调用,断言逐字未动;源 :70 事件日志落盘条经主会话裁决一 +# 改判集成测试——不随本宿主,见迁移账本转办清单)---- +func _smoke_initialize() -> void: + print("=== Dungeon Sweeper 垂直切片冒烟 ===") + var db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + Kit.check("全部表加载 + 校验通过(含跨表护栏)", load_ok) + if not load_ok: + for d in db.diagnostics(): + if d.severity == "error": + print(" error: [%s] %s" % [d.category, d.message]) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + # 统一流黄金向量(数据配置 AC-3:单盐沿用 board_gen 盐, + # scope 0-3 与 board_gen 向量逐位连续;Python 参考实现逐位交叉验证一致) + Kit.check("统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)", rng.derive_at(0) == 8672514112661456564) + Kit.check("统一流向量 m=42 scope=1", rng.derive_at(1) == 8834291164406037215) + Kit.check("统一流向量 m=42 scope=2", rng.derive_at(2) == 7714373262647006282) + Kit.check("统一流向量 m=42 scope=3", rng.derive_at(3) == 1280102845971471347) + Kit.check("统一流向量 m=42 scope=4", rng.derive_at(4) == 82585501446482399) + Kit.check("统一流向量 m=42 scope=5", rng.derive_at(5) == 8078924421474470922) + Kit.check("统一流向量 m=42 scope=6", rng.derive_at(6) == 6638052740430622424) + Kit.check("统一流向量 m=42 scope=7", rng.derive_at(7) == 8264196488417721294) + Kit.check("derive_at 纯函数恒等", rng.derive_at(0) == rng.derive_at(0)) + # scope 取号语义:首个 open_stream = 全局 scope 0;计数器单调;用途标签不参与派生(只进事件日志) + var s0 = rng.open_stream("board_gen") + var s1 = rng.open_stream("encounter") + Kit.check("open_stream 全局计数器单调 +1 ×2", rng.counter() == 2) + Kit.check("首个取号 = scope 0(顺序确定)", s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1)) + rng.new_adventure(4242) + var s2 = rng.open_stream("gp_drop") + Kit.check("new_adventure 重置全局计数器", rng.counter() == 1 and s2.seed == rng.derive_at(0)) diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd new file mode 100644 index 00000000..7ec9c00d --- /dev/null +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -0,0 +1,531 @@ +# tests/unit/data_config/table_structure_guard_test.gd — 表结构守卫单元测试宿主 +# 断言面 = data/tables 各表的结构锚与守卫,七套: +# adventure 冒险表(档位配额/件值对/同调目录 12 件/装配→战斗贯通) +# enemy 敌人表(20 行带键行序/hobgoblin 留任/三 Boss 行) +# AC 单轨与掷点参数(status_effect/reward/settlement 三处无双轨/免伤墙护栏/敌 AC 带宽) +# variation 地形变异(行数/候选池门控/跨字段互锁负样本) +# difficulty 难度表(空表零行为/修饰套乘/越域 clamp) +# reward 九型封闭与 gp 流(18 行字典/权重结构/derive_at 计数器) +# meta 元进度表(行族规模/词表封闭/first≥repeat) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入;随机只经 RngService 统一流(_fresh_rng 同种子隔离夹具)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/table_structure_guard_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +# 隔离存档名册(段内只读白板档——测试不污染 user://save.json 真实档) +const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json", + "user://smoke_meta_settle.json", "user://smoke_meta_workshop.json", + "user://smoke_meta_cap.json", "user://smoke_meta_white.json", + "user://smoke_meta_attune.json", "user://smoke_meta_attune2.json", + "user://smoke_meta_tree.json", "user://smoke_meta_career.json", + "user://smoke_meta_career_v5.json", "user://smoke_meta_treasure_unlock.json", "user://smoke_meta_treasure_blank.json", + "user://smoke_meta_forge.json", "user://smoke_meta_forge2.json", "user://smoke_meta_forge3.json", + "user://smoke_meta_forge4.json", "user://smoke_meta_forge5.json"] + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(沿被迁断言面的源语义:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[table_structure_guard_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排序(difficulty 的修饰行段内自清空,不影响后续套的表基线) + _smoke_ac_swap_guard() + _smoke_enemy_bands() + _smoke_variation_framework() + _smoke_difficulty_table() + _smoke_nine_types_gp_stream() + _smoke_adventure_table() + _smoke_meta() + quit(Kit.finish("table_structure_guard_test")) + + +# ---- 隔离夹具(无 _db 依赖者为 static——后续宿主可 preload 复用)---- + +# 隔离用全新 RngService(同 master 种子——回放确定性纪律) +static func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# 隔离档只读开启(不写盘——写侧归 meta 域宿主) +static func _meta_new_save(path: String): + var sv = load("res://core/save_manager.gd").new(path) + sv.load_from_disk() + return sv + + +static func _errs_has_cross_field(errs: Array) -> bool: + for e in errs: + if String(e.get("category", "")) == "cross_field": + return true + return false + + +# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) +func _boss_depth(level_index: int) -> int: + for n in _level_active_nodes(level_index): + if String(n.get("type", "")) == "boss": + return int(n.get("node_depth", -1)) + return -1 + + +# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 +func _first_encounter_band(level_index: int, enemy_id: String) -> int: + var best := -1 + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) != level_index: + continue + if String(row.get("row_kind", "")) != "battle" or String(row.get("enemy_id", "")) != enemy_id: + continue + var band := int(row.get("depth_band", -1)) + if best < 0 or band < best: + best = band + return best + + +# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) +func _level_active_nodes(level_index: int) -> Array: + for row in _db.level.rows: + if int(row.level_index) == level_index: + var tpls: Array = row.get("templates", []) + if not tpls.is_empty(): + return tpls[0].get("nodes", []) + return row.get("nodes", []) + return [] + + +# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检) +func _smoke_adventure_table() -> void: + print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") + Kit.check("db.adventure 已加载(adventure.tres 入 manifest)", _db.adventure != null) + if _db.adventure == null: + return + Kit.check("AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)", _db.adventure.validate().is_empty()) + Kit.check("clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct), + int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100) + for tier in _db.adventure.TIERS: + Kit.check("档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])], + int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier])) + Kit.check("令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕", int(_db.adventure.deck_total()) == 102) + Kit.check("数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2", + int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 + and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 + and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 + and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2) + Kit.check("恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕", + int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1) + Kit.check("cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕", + int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99) + Kit.check("同调位读数 null 档早退分支求值 0(迭代 21 T4 槽位收编——谓词表已退役)〔天赋树批 V14·核代码增补〕", + Attunement.slots(_db, null) == 0) + Kit.check("同调位读数源=树同调位节点点亮数(非空档——socket1 点亮 1 件计 1)〔天赋树批 V14·核代码增补〕", + Attunement.slots(_db, _TreeSaveMock.new()) == 1) + # 同调目录 12 件(V1/V2 断言落点;装配→战斗贯通与空装配基线的现行主语 = morning_order) + var trows: Array = _db.treasure_attunement_rows() + var tid_set := {} + for r in trows: + tid_set[String(r.get("item_id", ""))] = true + Kit.check("新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕", + trows.size() == 12 and tid_set.size() == 12) + var new_names := {"morning_order": "晨操令", "wide_scout_band": "阔视野带", "trial_pick": "试炼镐", + "refresh_practice": "刷新练习", "reserve_jar": "储备罐", "nest_forecast": "巢穴预告", + "incense_stick": "线香", "twin_loader": "双管装填", "ranged_pick": "远程镐", + "refresh_license": "刷新执照", "sky_eye": "天眼", "beacon_network": "信标网络"} + var names_ok := tid_set.size() == new_names.size() + for r in trows: + var iid2 := String(r.get("item_id", "")) + if not new_names.has(iid2) or String(r.get("display_name", "")) != String(new_names[iid2]): + names_ok = false + Kit.check("命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕", names_ok) + var keystone_ids := ["morning_order", "wide_scout_band", "trial_pick", "refresh_practice", + "reserve_jar", "nest_forecast", "incense_stick", "twin_loader"] + var cond_reqs := {"ranged_pick": {"adventure_cleared": 1}, "refresh_license": {"boss_kill_count": 3}, + "sky_eye": {"adventure_cleared": 3, "boss_kill_count": 4}, "beacon_network": {"vision_used": 10}} + var tier_map := {"common": 0, "excellent": 0, "rare": 0} + var alloc_ok := trows.size() == 12 + for r in trows: + var iid3 := String(r.get("item_id", "")) + var ureqs: Array = r.get("unlock_requires", []) as Array + if keystone_ids.has(iid3): + if int(r.get("unlock_keystone_cost", 0)) < 1 or not ureqs.is_empty(): + alloc_ok = false + elif cond_reqs.has(iid3): + var want: Dictionary = cond_reqs[iid3] + if int(r.get("unlock_keystone_cost", 0)) != 0 or ureqs.size() != want.size(): + alloc_ok = false + for req_v in ureqs: + var req: Dictionary = req_v as Dictionary + if int(want.get(String(req.get("key", "")), -1)) != int(req.get("threshold", -1)): + alloc_ok = false + else: + alloc_ok = false + var t: String = String(r.get("tier", "")) + if not tier_map.has(t): + alloc_ok = false + else: + tier_map[t] += 1 + Kit.check("逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕", + alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2) + var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191)) + Kit.check("装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕", + orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) + and orch_l.attune_has("morning_order")) + var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192)) + Kit.check("空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕", + orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + and not orch_e.attune_has("morning_order")) + # V9 静态词表断言(棋盘事实不可变契约 3:12 行 triggers do.kind 与 effect_kinds + # 全集零棋盘改写类词〔rewrite_board/remove_mines/cell_removal=改写族——现表零引用〕; + # 效果只允许信息呈现与开格动作) + var rewrite_words := ["rewrite_board", "remove_mines", "cell_removal"] + var v9_ok := true + for r9 in trows: + for k9 in (r9.get("effect_kinds", []) as Array): + if rewrite_words.has(String(k9)): + v9_ok = false + for tr9 in (r9.get("triggers", []) as Array): + if rewrite_words.has(String((tr9 as Dictionary).get("do", {}).get("kind", ""))): + v9_ok = false + Kit.check("新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕", v9_ok) + # 覆盖检查(效果三方向覆盖:信息〔巢穴预告/线香/天眼/阔视野带〕/行动资源〔晨操令/ + # 双管装填/试炼镐/储备罐〕/操作能力〔远程镐/信标网络〕+抉择刷新〔刷新练习/刷新执照〕——dimension_tags 静态投影) + var tag_set := {} + for r10 in trows: + for tg in (r10.get("dimension_tags", []) as Array): + tag_set[String(tg)] = true + Kit.check("效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕", + tag_set.has("info") and tag_set.has("action") and tag_set.has("plan")) + # V18 装配面空目录合法态(白板档全未解锁:柜台投影 12 行全未解锁+mode 分派正确; + # 池空兜底/商店空货架两部分归奇物/商店侧断言面〔卡片 §4〕) + var mp_v18 = load("res://core/meta_progression.gd").new(_db, _meta_new_save(_META_SMOKE_PATHS[12])) + var v18_ok := true + var locked_n := 0 + for st18 in mp_v18.treasure_shop_status(): + if bool(st18.get("unlocked", false)): + v18_ok = false + else: + locked_n += 1 + if (String(st18.get("mode", "")) != "keystone" and String(st18.get("mode", "")) != "condition"): + v18_ok = false + Kit.check("装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕", + v18_ok and locked_n == 12) + + +# ---- 敌人表深度带键结构(迭代 34:20 行键迁移 · 键序=迁移表序 · hobgoblin 留任 · 三 Boss 行 kind/band)---- + +func _smoke_enemy_bands() -> void: + print("--- 敌人表深度带键结构(20 行键序 · hobgoblin 留任 · 三 Boss 行)---") + # ① 20 行计数 + 键序 = 迁移表序(文件行序不变——行序即契约) + var expect_keys: Array[String] = [ + "1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin", + "1|boss|4|ogre_chief", + "2|battle|0|bugbear", "2|battle|1|bugbear", "2|battle|4|giant_spider", + "2|battle|5|bugbear", "2|battle|5|giant_spider", "2|battle|6|giant_spider", "2|battle|7|giant_spider", + "2|boss|9|drider", + "3|battle|0|duergar", "3|battle|1|hook_horror", "3|battle|4|hook_horror", "3|battle|5|duergar", + "3|battle|6|hook_horror", "3|battle|7|duergar", "3|boss|9|duergar_king", + ] + var got_keys: Array[String] = [] + for row in _db.enemy.rows: + got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")), + int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))]) + Kit.check("敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕", + _db.enemy.rows.size() == 20 and got_keys == expect_keys) + # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) + var hob := {} + for row in _db.enemy.rows: + if String(row.get("enemy_id", "")) == "hobgoblin": + hob = row + Kit.check("hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕", + int(hob.get("level_index", -1)) == 1 and String(hob.get("row_kind", "")) == "battle" \ + and int(hob.get("depth_band", -1)) == 3) + Kit.check("hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕", + FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行")) + # ③ 三 Boss 行 kind/band 就位(band = 该关图 Boss 节点深度——单终点图数据驱动) + var boss_ok := true + var boss_detail := "" + for L in [1, 2, 3]: + var bdepth := _boss_depth(L) + var hits := 0 + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) == L and String(row.get("row_kind", "")) == "boss": + hits += 1 + if int(row.get("depth_band", -1)) != bdepth: + boss_ok = false + boss_detail += " L%d:%d≠%d" % [L, int(row.get("depth_band", -1)), bdepth] + if hits != 1: + boss_ok = false + boss_detail += " L%d:boss 行×%d" % [L, hits] + Kit.check("三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [ + _boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail], boss_ok) + + +# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行 +# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(池内现无 percent 行——本断言防未来负向行回归时静默造墙); +# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物文档); +# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。 + +func _smoke_ac_swap_guard() -> void: + print("--- 护甲等级掷点批·换装护栏 ---") + # ① 三处无双轨 + Kit.check("状态表 defense_up 行已退役(无双轨)", _db.status_effect_row("defense_up").is_empty()) + var ac_row: Dictionary = _db.status_effect_row("ac_up") + Kit.check("ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)", + not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \ + and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \ + and String(ac_row.get("stack_policy", "")) == "refresh") + var ac_reward := {} + for row in _db.reward.rows: + if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive": + ac_reward = row + Kit.check("奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)", + String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护") + Kit.check("effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)", + not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up")) + # ② 免伤墙归零护栏(泛化——BUG-0003 免疫) + var wall_ok := true + var wall_detail := "" + for eff_id in _db.settlement.effect_pool_map.keys(): + if String(_db.settlement.effect_pool_map[eff_id]) != "hero_defense": + continue + var row: Dictionary = _db.status_effect_row(eff_id) + if row.is_empty() or String(row.get("unit", "")) != "percent": + continue + var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1)) + if floor_v <= int(_db.settlement.pool_clamp_lo): + wall_ok = false + wall_detail += " %s(%d)" % [eff_id, floor_v] + Kit.check("hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [ + wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)], wall_ok) + var hd_maps := 0 + for eff_id in _db.settlement.effect_pool_map.keys(): + if String(_db.settlement.effect_pool_map[eff_id]) == "hero_defense": + hd_maps += 1 + Kit.check("hero_defense 池映射数==0(绊线钉——红=已加映射,去核上方护栏是否已同步激活)", hd_maps == 0) + # ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚) + Kit.check("主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class), + int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30) + var ac_band := true + var ac_detail := "" + var heavy_names := ["熊地精重击手", "灰矮人重装手"] # 逐行手设值 8/9,非精英编码位 + for row in _db.enemy.rows: + var ac: int = int(row.get("armor_class", -1)) + var dname := String(row.get("display_name", "?")) + var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \ + String(row.get("enemy_id", "")) == "drider" or String(row.get("enemy_id", "")) == "duergar_king" + var heavy_row: bool = dname in heavy_names + # 杂鱼 0~5;重击手/重装手 8~9(手设精英值);Boss 8~12 台阶 + var lo := 8 if (boss_row or heavy_row) else 0 + var hi := 12 if boss_row else (9 if heavy_row else 5) + if ac < lo or ac > hi: + ac_band = false + ac_detail += " %s:%d" % [dname, ac] + Kit.check("敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [ + ac_detail if ac_detail != "" else "无"], ac_band) + # ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3) + var boss_ac := {} + for row in _db.enemy.rows: + var eid := String(row.get("enemy_id", "")) + if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king": + boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0)) + Kit.check("Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))], + int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0))) + # ③c 熊地精首遇带降档锚定 + 第一关总量曲线护栏 + # 首遇带行 = 数据驱动定位(契约 8:该 (1,battle,bugbear) 的最低 depth_band)锚定值 hp 22 / ac 2; + # 敌种 bugbear 与池 attack6/charge2/heavy2 钉死不动(防顺手改多); + # 曲线护栏 = 深度带升序相邻带「hp × 中位数量」总量比 ≤ 2.5(12/10/22/35/72 现行全绿;不用单调不回撤——会红在首带→次带) + var l12_band := _first_encounter_band(1, "bugbear") + var l12_row: Dictionary = {} + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) == 1 and String(row.get("row_kind", "")) == "battle" \ + and int(row.get("depth_band", -1)) == l12_band and String(row.get("enemy_id", "")) == "bugbear": + l12_row = row + break + var l12_pool := {} + for entry in l12_row.get("pool", []): + l12_pool[String(entry.skill_id)] = int(entry.weight) + Kit.check("熊地精首遇带(1,battle,%d)降档锚定(hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1;首遇带数据驱动定位)〔fv21〕" % l12_band, + l12_band >= 0 and not l12_row.is_empty() and int(l12_row.get("hp", -1)) == 22 \ + and int(l12_row.get("armor_class", -1)) == 2 \ + and l12_pool == {"attack": 6, "charge": 2, "heavy": 2}) + var l1_totals := {} + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) != 1: + continue + var cnt_lo: int = int(row.get("unit_count", 1)) + var cnt_hi: int = int(row.get("unit_count_max", cnt_lo)) + l1_totals[int(row.get("depth_band", -1))] = int(row.get("hp", 0)) * (cnt_lo + cnt_hi) / 2.0 + var curve_ok := true + var curve_detail := "" + var depths_sorted := l1_totals.keys() + depths_sorted.sort() + for i in range(1, depths_sorted.size()): + var prev: float = float(l1_totals[depths_sorted[i - 1]]) + var curr: float = float(l1_totals[depths_sorted[i]]) + var ratio: float = curr / prev if prev > 0.0 else 999.0 + if ratio > 2.5: + curve_ok = false + curve_detail += " (%d→%d):%.2f" % [depths_sorted[i - 1], depths_sorted[i], ratio] + Kit.check("第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" % [ + curve_detail if curve_detail != "" else "无"], curve_ok) + # ④ 掷点参数域(加载校验已过——此处钉形状语义) + var bounds: Array = _db.settlement.roll_tier_bounds + var discounts: Array = _db.settlement.roll_tier_discounts + Kit.check("掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)", + int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \ + and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \ + and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll") + Kit.check("档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)", + bounds == [3, 7] and discounts == [75, 50, 25]) + + +# ---- 地形变异 框架面(四行两轴×两关 + 门控 + 跨字段互锁)---- + +func _smoke_variation_framework() -> void: + print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---") + Kit.check("变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)", + _db.variation.rows.size() == 6) + Kit.check("变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)", + _db.variation.validate().is_empty()) + var pool1_ids: Array = [] + for r in _db.variant_pool(1, "dungeon"): + pool1_ids.append(String(r.variant_id)) + Kit.check("L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)", pool1_ids == ["thorn", "vine"]) + var pool2_ids: Array = [] + for r in _db.variant_pool(2, "underdark"): + pool2_ids.append(String(r.variant_id)) + Kit.check("L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)", pool2_ids == ["magma", "ash"]) + Kit.check("unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)", _db.variant_pool(1, "underdark").is_empty()) + var pool2d_ids: Array = [] + for r in _db.variant_pool(2, "dungeon"): + pool2d_ids.append(String(r.variant_id)) + Kit.check("board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)", + pool2d_ids == ["thorn", "vine"]) + Kit.check("清单外 variant_id 返回空(读取通道在位)", _db.variation.row_by_id("nonexistent").is_empty()) + # 跨字段互锁负样本(加载期致命——枚举语义一致性闸) + var vt = load("res://data/schemas/variation_table.gd").new() + vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region", + "effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)", _errs_has_cross_field(vt.validate())) + vt.rows.assign([{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region", + "effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒", _errs_has_cross_field(vt.validate())) + + +# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)---- + +func _smoke_difficulty_table() -> void: + print("--- 难度曲线与Boss·DifficultyTable 结构位 ---") + Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.difficulty != null and _db.difficulty.rows.is_empty()) + # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 + var enemy_system = load("res://core/unit_container.gd").new(_db) + Kit.check("spawn 受理", enemy_system.spawn(1, 0, int(_db.hero.max_hp))) + var unit = enemy_system.all_enemies()[0] + var d0: Dictionary = _db.resolve_enemy_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) + Kit.check("空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)], + int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk)) + # ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk + _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) + var enemy_system_b = load("res://core/unit_container.gd").new(_db) + Kit.check("修饰后 spawn 受理", enemy_system_b.spawn(1, 0, int(_db.hero.max_hp))) + var g2 = enemy_system_b.all_enemies()[0] + Kit.check("hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)], + int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp)) + Kit.check("atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)], int(g2.atk) == int(d0.atk) * 2) + # ③ 越域 clamp:乘数 50 → clamp 10 + _db.difficulty.rows.clear() + _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) + var enemy_system_c = load("res://core/unit_container.gd").new(_db) + Kit.check("越域修饰 spawn 受理", enemy_system_c.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)], + int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10) + _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) + + +# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)---- + +func _smoke_nine_types_gp_stream() -> void: + print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---") + Kit.check("格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)", + RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item")) + var cell_rows := 0 + var pos_weight := 0 + var gp_pos := -1 + var gp_neg := -1 + for row in _db.reward.rows: + if String(row.get("pool_tag", "")) == "cell": + cell_rows += 1 + if String(row.get("polarity", "")) == "positive": + pos_weight += int(row.get("weight", 0)) + if String(row.get("reward_id", "")) == "gp_coin": + gp_pos = int(row.weight) + if String(row.get("reward_id", "")) == "gp_lose": + gp_neg = int(row.weight) + Kit.check("奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)", + cell_rows == 18) + # 危险格负向直伤当量(负向≈正向 2 倍定价——真人反馈「三雷 3/4 血无后果感」的投值口径) + var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 危险格负向直伤当量(抬档值;依据见 numfix 报告) + for row in _db.reward.rows: + var rid := String(row.get("reward_id", "")) + if mine_power.has(rid): + Kit.check("危险格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])], + int(row.get("power", -1)) == int(mine_power[rid])) + # 正向权重结构校验(配置推导):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、 + # 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。 + var pos_all_in_pool := true + var neg_all_zero := true + for row in _db.reward.rows: + if String(row.get("pool_tag", "")) != "cell": + continue + if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1: + pos_all_in_pool = false + if String(row.get("polarity", "")) == "negative" and int(row.get("weight", 0)) != 0: + neg_all_zero = false + Kit.check("正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight, + pos_all_in_pool and neg_all_zero) + Kit.check("gp 型两行(GP 袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)", gp_pos == 2 and gp_neg == 0) + var rng = _fresh_rng(42) + for i in 3: + rng.derive_at(i) + Kit.check("derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)", + rng.counter() == 0) + + +# ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; +# conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- +func _smoke_meta() -> void: + print("--- 元进度·MetaTable 表结构 ---") + var meta_tbl = _db.meta + Kit.check("MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)", + meta_tbl != null and meta_tbl.validate().is_empty()) + Kit.check("MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)", + meta_tbl.boss_keystone.size() == 3 + and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2) + var attrs_ok := true + for a in ["max_hp", "atk", "armor_class"]: + attrs_ok = attrs_ok and not _db.meta_conversion_row(a).is_empty() + Kit.check("conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)", attrs_ok) + var boss_ok := true + for li in [1, 2, 3]: + var brow: Dictionary = _db.meta_boss_keystone_row(li) + boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1)) + Kit.check("boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)", boss_ok) + + +class _TreeSaveMock: + # 核代码增补:最小 save 形态——attunement.slots 只读 tree_level("att_socketN") + func tree_level(n: String) -> int: + return 1 if n == "att_socket1" else 0 diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd.uid b/game/tests/unit/data_config/table_structure_guard_test.gd.uid new file mode 100644 index 00000000..763770f4 --- /dev/null +++ b/game/tests/unit/data_config/table_structure_guard_test.gd.uid @@ -0,0 +1 @@ +uid://decsopim6t4x5 diff --git a/game/tests/unit/presentation/theme_projection_test.gd b/game/tests/unit/presentation/theme_projection_test.gd new file mode 100644 index 00000000..c63b6838 --- /dev/null +++ b/game/tests/unit/presentation/theme_projection_test.gd @@ -0,0 +1,867 @@ +# tests/unit/presentation/theme_projection_test.gd — 呈现投影与主题单元测试宿主 +# 断言面 = 十套(批 B 断言迁移自 smoke_all.gd,G11 施工组;十套按源编排序迁入): +# 未开格类型微光(作用集恒等/九型归组封闭/零玩法写入——board_view 微光数据面) +# 战斗日志视图行格式化(行协议 9 类呈现层同步闸——真人试玩卡断 bug 回归) +# UI 全局主题(main_theme.tres 挂载/类型覆盖/滑轨两段对比/退役实现零残留/中文字体防御) +# UI 按钮五态矩阵(四工厂五态样式盒逐实例 override) +# UI 全局纹理过滤(工程 Nearest+平滑渐变件 LINEAR 白名单+光柱 alpha 结构) +# UI 残余 <4.5:1 文字位换墨(站桩徽标两族亮档+RAP 行深石墨对比度锚定) +# UI 元素细节余②③(BCard 图标槽明度差降档+商店居中+营地四栏等高源码锚) +# UI 诊断转储钮退役(玩家画面清零+浮窗可达——源码契约) +# 状态面板只读投影一致性(展示值逐项 == MetaProgression 访问器读数) +# 生涯页只读投影一致性(汇总段六类九项逐行 == SaveManager/MetaProgression 访问器读数+v5 旧档显示) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB/RngService 显式实例化构造注入(统一流 master 种子 42 沿源);表加载失败不中止 +# (沿被迁断言面的源语义——诊断在此展开,红由具体断言落,承 G2–G5 同款);全局 class_name +# (UiKit)裸用(--script 模式全局类在册,G3 先例)。 +# 分宿主注记:源 `_smoke_playtest_logger`(挂真实结算流跨系统)经主会话裁决七改判集成测试, +# 不随本宿主——见迁移账本转办清单;`_smoke_global_theme` 依赖 ThemeDB 项目主题在座, +# 裁决四要求实跑验证(本批零测试运行,验证归装配验证轮)。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/theme_projection_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) + +var _db +var _rng + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(承 G2–G5 同款:表损坏的红落在具体断言上) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[theme_projection_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + _rng = load("res://core/rng_service.gd").new() + _rng.set_master_seed(42) + # 套执行序沿源编排序(_run_core_smoke 序 + _smoke_meta 薄壳尾序) + _smoke_type_glow() + _smoke_battle_log_view() + _smoke_global_theme() + _smoke_button_five_states() + _smoke_texture_filter() + _smoke_contrast_inks() + _smoke_element_details() + _smoke_diag_button_retired() + _status_panel_projection() + _career_overlay_projection() + quit(Kit.finish("theme_projection_test")) + + +# ---- 隔离夹具(smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)---- + +# WCAG 相对亮度/对比度(滑轨两段对比度断言用) +func _theme_rel_luminance(c: Color) -> float: + var lin := func(ch: float) -> float: + return ch / 12.92 if ch <= 0.03928 else pow((ch + 0.055) / 1.055, 2.4) + return 0.2126 * lin.call(c.r) + 0.7152 * lin.call(c.g) + 0.0722 * lin.call(c.b) + + +func _theme_contrast(a: Color, b: Color) -> float: + var la := _theme_rel_luminance(a) + var lb := _theme_rel_luminance(b) + return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05) + + +# 定域流桩(scope 派生种子——derive_at 纯函数,与取号计数器状态无关) +func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator: + # stream_name = 调用点自述标签(不参与派生),保留参数零改调用方 + var rg := RandomNumberGenerator.new() + rg.seed = _rng.derive_at(scope) + return rg + + +# 排雷 core 态摘要(同板同序列对照面——已开记录含 kind/digit、封印集、英雄位、AP、相位) +func _ms_digest(ms, ae) -> Array: + var b: Variant = ms.current_board() + var rev: Array = [] + for c in ms.revealed_cells(): + var rec: Dictionary = ms.reveal_record(c) + rev.append([int(c.x), int(c.y), String(rec.get("kind", "")), int(rec.get("digit", -1))]) + rev.sort() + var seals: Array = [] + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if ms.has_seal(c): + seals.append([int(c.x), int(c.y)]) + return [[int(ms.hero_pos().x), int(ms.hero_pos().y)], rev, seals, ae.ap, ae.phase, ms.seal_count()] + + +# 微光夹具身份解析桩:位点覆盖表 _glow_type_overrides(Vector2i → cell_type)+封锁集 +# _glow_blocked(Vector2i → true,返回 null = 不在摇号清单的封锁格)。 +# 哈希兜底按坐标确定性散列——全缸可复现。 +const _GLOW_FIXTURE_TYPES := ["attack", "defense", "blast", "heal", "weaken", + "pierce", "magic", "action", "gp"] +var _glow_type_overrides := {} +var _glow_blocked := {} + + +func _glow_fixture_identity(c: Vector2i) -> Variant: + if _glow_blocked.has(c): + return null + var ctype := String(_glow_type_overrides.get(c, "")) + if ctype == "": + ctype = _GLOW_FIXTURE_TYPES[int(abs(c.x * 31 + c.y * 17)) % _GLOW_FIXTURE_TYPES.size()] + return {"reward_id": "fix_%d_%d" % [c.x, c.y], "cell_type": ctype, "tier": 1} + + +# 英雄 8 邻中未开且未移除的格数(夹具起板前提——初始展开常把 8 邻全揭示) +func _glow_unrevealed_neighbors(ms) -> int: + var n := 0 + var hero: Vector2i = ms.hero_pos() + for c in ms.current_board().neighbors8(hero): + if not ms.is_revealed(c) and not ms.is_removed(c): + n += 1 + return n + + +# 独立重算的期望作用集(不读 BoardView 私有域——恒等断言的两侧各自算) +func _glow_expected_set(b, ms) -> Dictionary: + var out := {} + var hero: Vector2i = ms.hero_pos() + for n in b.neighbors8(hero): + if not ms.is_revealed(n) and not ms.is_removed(n): + out[n] = true + return out + + +# 键集相等(期望集是纯键集——V1 恒等面对照) +func _glow_key_set_equal(a: Dictionary, b: Dictionary) -> bool: + if a.size() != b.size(): + return false + for k in a: + if not b.has(k): + return false + return true + + +# 键值逐位相等(off→on 复现——两侧都是作用集快照,组号亦须逐位一致) +func _glow_map_equal(a: Dictionary, b: Dictionary) -> bool: + if a.size() != b.size(): + return false + for k in a: + if not b.has(k) or int(a[k]) != int(b[k]): + return false + return true + + +# 元进度隔离档夹具(_status_panel_projection/_career_overlay_projection 消费——G9 同名 helper +# 的宿主侧私有副本,双份过渡,收尾批去重) +const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json", + "user://smoke_meta_settle.json", "user://smoke_meta_workshop.json", + "user://smoke_meta_cap.json", "user://smoke_meta_white.json", + "user://smoke_meta_attune.json", "user://smoke_meta_attune2.json", + "user://smoke_meta_tree.json", "user://smoke_meta_career.json", + "user://smoke_meta_career_v5.json", "user://smoke_meta_treasure_unlock.json", "user://smoke_meta_treasure_blank.json", + "user://smoke_meta_forge.json", "user://smoke_meta_forge2.json", "user://smoke_meta_forge3.json", + "user://smoke_meta_forge4.json", "user://smoke_meta_forge5.json"] + + +func _meta_smoke_cleanup() -> void: + for p in _META_SMOKE_PATHS: + for suffix in ["", ".tmp", ".corrupt.bak"]: + if FileAccess.file_exists(p + suffix): + DirAccess.remove_absolute(p + suffix) + + +func _meta_new_save(path: String): + var sv = load("res://core/save_manager.gd").new(path) + sv.load_from_disk() + return sv + + +# ---- 未开格类型微光(作用集恒等/九型归组封闭/零玩法写入)---- +func _smoke_type_glow() -> void: + print("--- 未开格类型微光(作用集恒等/九型归组封闭/零玩法写入)---") + var board_generator = load("res://core/board_generator.gd").new() + var b = board_generator.generate(1, 0, _mk_stream("board_gen", 4242), _db) + var ae = load("res://core/action_economy.gd").new(_db) + var ms = load("res://core/minesweeper_rules.gd").new(_db, ae) + ms.install_board(b) + ae.notify_round_start() + # 新手策略驱动到「英雄邻域有未开格」态(初始展开常把 8 邻全揭示——取证探针同款起手法; + # 恒定种子确定性,步数上限 40 防冻态空转) + var steps := 0 + while _glow_unrevealed_neighbors(ms) < 5 and steps < 40: + var act: Dictionary = AutoPlayer.novice_action(ms) + if String(act.get("kind", "stop")) == "stop": + break + var op := String(act.get("kind", "")) + if op == "seal": + op = "seal_place" + ms.settle_batch([{"action": op, "cell": act.get("cell")}]) + steps += 1 + var view = load("res://presentation/views/board_view.gd").new() + view.set_identity_resolver(_glow_fixture_identity) + view.rebuild(b, ms) + view.set_dig_glow_enabled(true) + var hero: Vector2i = ms.hero_pos() + # 快照 trio:棋盘态(零玩法写入的对照面)+作用集 + var revealed_before: Array = ms.revealed_cells() + revealed_before.sort() + var hero_before: Vector2i = ms.hero_pos() + var seals_before: int = ms.seal_count() + # ---- 相 0:夹具全集合法九型 ⇒ 作用集 == 独立重算期望集(V1)---- + var snap: Dictionary = view.type_glow_probe() + Kit.check("V1 微光默认开(Q4 丙案——呈现态布尔缺省 true,生产写入面零显式开)", + bool(snap.get("enabled", false))) + Kit.check("V1 夹具全集合法九型 ⇒ 清单外登记为空(禁静默空光通道零误报)", + (snap.get("errors", []) as Array).is_empty()) + var expect: Dictionary = _glow_expected_set(b, ms) + Kit.check("V1 作用集恒等:微光格集合 == _diggable ∩ 身份可查(期望 %d,实测 %d)" % [expect.size(), (snap.get("cells", {}) as Dictionary).size()], + _glow_key_set_equal(snap.get("cells", {}), expect)) + Kit.check("V1 夹具前提:英雄未开邻格 ≥5(实测 %d——novice %d 步)" % [expect.size(), steps], + expect.size() >= 5) + # 已开格/r0 出生展开区零微光(S1:已开格不显示) + var opened_leak := "" + for c in ms.revealed_cells(): + if (snap.get("cells", {}) as Dictionary).has(c): + opened_leak += str(c) + " " + for c in b.compute_r0(): + if (snap.get("cells", {}) as Dictionary).has(c): + opened_leak += "r0:" + str(c) + " " + Kit.check("V1 已开格/r0 出生展开区零微光(S1)%s" % ("" if opened_leak == "" else "违例:" + opened_leak), + opened_leak == "") + # ---- 相 1:八邻派定八型 ⇒ 五组逐格映射(V2 活板面)---- + var nbs: Array = b.neighbors8(hero) + var assign := ["attack", "heal", "pierce", "blast", "magic", "defense", "action", "gp"] + var expect_groups := {"attack": UiKit.GLOW_BUFF, "defense": UiKit.GLOW_BUFF, "weaken": UiKit.GLOW_BUFF, + "action": UiKit.GLOW_BUFF, "gp": UiKit.GLOW_BUFF, "heal": UiKit.GLOW_HEAL, + "pierce": UiKit.GLOW_PIERCE, "blast": UiKit.GLOW_AOE, "magic": UiKit.GLOW_MAGIC} + var mapped := 0 + for i in mini(assign.size(), nbs.size()): + var nb: Vector2i = nbs[i] + if ms.is_revealed(nb) or ms.is_removed(nb): + continue # 已开邻格不派定——只测未开格 + _glow_type_overrides[nb] = assign[i] + mapped += 1 + view.refresh() + snap = view.type_glow_probe() + Kit.check("V2 夹具前提:未开邻格派定 ≥5 型(实测 %d)" % mapped, mapped >= 5) + var map_bad := "" + for i in mini(assign.size(), nbs.size()): + var nb2: Vector2i = nbs[i] + if not _glow_type_overrides.has(nb2): + continue + var got: int = int((snap.get("cells", {}) as Dictionary).get(nb2, 0)) + if got != int(expect_groups[assign[i]]): + map_bad += "%s:%s→组%d(期望组%d) " % [str(nb2), assign[i], got, int(expect_groups[assign[i]])] + Kit.check("V2 九型→五组逐格映射(attack/defense/action/gp→增益/heal→治疗/pierce→穿刺/blast→群伤/magic→魔法)%s" + % ("" if map_bad == "" else "违例:" + map_bad), + map_bad == "") + # ---- 相 2:清单外 cell_type ⇒ 显式登记 + 该格无微光(V2 禁静默空光)---- + var bogus_cell: Vector2i = hero + for i in mini(assign.size(), nbs.size()): + var nb3: Vector2i = nbs[i] + if _glow_type_overrides.has(nb3): + bogus_cell = nb3 + break + _glow_type_overrides[bogus_cell] = "keystone" # 大宝箱未来第十型之外的清单外值 + view.refresh() + snap = view.type_glow_probe() + var errs: Array = snap.get("errors", []) + Kit.check("V2 清单外 cell_type 显式登记(errors=%s)" % str(errs), errs.has("keystone")) + Kit.check("V2 清单外格零微光(无静默空光——%s 不在作用集)" % str(bogus_cell), + not (snap.get("cells", {}) as Dictionary).has(bogus_cell)) + Kit.check("V2 清单外格剔除后作用集 = 派定数 −1(%d→%d)" % [mapped, (snap.get("cells", {}) as Dictionary).size()], + (snap.get("cells", {}) as Dictionary).size() == mapped - 1) + # ---- 相 3:封锁格(query 返回 null)⇒ 无微光且零登记 ---- + _glow_type_overrides.erase(bogus_cell) # 复位清单外(errors 保留=整图登记语义) + var blocked_cell: Vector2i = hero + for i in mini(assign.size(), nbs.size()): + var nb4: Vector2i = nbs[i] + if _glow_type_overrides.has(nb4): + blocked_cell = nb4 + break + _glow_blocked[blocked_cell] = true + view.refresh() + snap = view.type_glow_probe() + Kit.check("V1 封锁格零微光(query null ⇒ 天然惰性——%s)" % str(blocked_cell), + not (snap.get("cells", {}) as Dictionary).has(blocked_cell)) + Kit.check("V1 封锁格零登记(errors 仍仅清单外一条——%s)" % str(snap.get("errors", [])), + (snap.get("errors", []) as Array).size() == 1) + Kit.check("V1 封锁格剔除后作用集 = 派定数 −1(实测 %d)" % (snap.get("cells", {}) as Dictionary).size(), + (snap.get("cells", {}) as Dictionary).size() == mapped - 1) + # ---- 相 4:封锁解除 ⇒ 作用集复位(进出圈复亮走回)---- + _glow_blocked.erase(blocked_cell) + view.refresh() + snap = view.type_glow_probe() + Kit.check("V1 封锁解除后作用集复位恒等(期望 %d,实测 %d)" % [expect.size(), (snap.get("cells", {}) as Dictionary).size()], + _glow_key_set_equal(snap.get("cells", {}), expect)) + # ---- 相 5:开关 off/on(V7——off 态空+on 态逐位复现+棋盘态零扰动)---- + var snap_before_off: Dictionary = view.type_glow_probe() + view.set_type_glow_enabled(false) + snap = view.type_glow_probe() + Kit.check("V7 off 态开关读数=false", not bool(snap.get("enabled", true))) + Kit.check("V7 off 态作用集为空(基线保护面)", (snap.get("cells", {}) as Dictionary).is_empty()) + view.set_type_glow_enabled(true) + var snap_on: Dictionary = view.type_glow_probe() + Kit.check("V7 on 态开关读数=true(复现)", bool(snap_on.get("enabled", false))) + Kit.check("V7 off→on 作用集逐位复现(关前 %d 格/开后 %d 格,组号逐位一致)" % [ + (snap_before_off.get("cells", {}) as Dictionary).size(), (snap_on.get("cells", {}) as Dictionary).size()], + _glow_map_equal(snap_on.get("cells", {}), snap_before_off.get("cells", {}))) + Kit.check("V7 off→on 作用集与独立重算期望集恒等(%d 格)" % (snap_on.get("cells", {}) as Dictionary).size(), + _glow_key_set_equal(snap_on.get("cells", {}), expect)) + var revealed_after: Array = ms.revealed_cells() + revealed_after.sort() + Kit.check("V7 微光开关零玩法写入(已开集/英雄位/封印数三面对照逐位一致)", + revealed_before == revealed_after and hero_before == ms.hero_pos() and seals_before == ms.seal_count()) + # ---- 相 6:换图随 rebuild 重算(V3——旧板坐标零残留)---- + _glow_type_overrides.clear() + _glow_blocked.clear() + var b2 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), _db) + var ae2 = load("res://core/action_economy.gd").new(_db) + var ms2 = load("res://core/minesweeper_rules.gd").new(_db, ae2) + ms2.install_board(b2) + ae2.notify_round_start() + view.rebuild(b2, ms2) + var snap2: Dictionary = view.type_glow_probe() + var expect2: Dictionary = _glow_expected_set(b2, ms2) + Kit.check("V3 换图 rebuild 后作用集按新板重算(期望 %d,实测 %d——零旧板残留)" % [expect2.size(), (snap2.get("cells", {}) as Dictionary).size()], + _glow_key_set_equal(snap2.get("cells", {}), expect2)) + Kit.check("V3 换图清清单外登记(rebuild 清零)", (snap2.get("errors", []) as Array).is_empty()) + view.free() + # ---- 相 7:同板同序列 on/off 双实例结算逐位一致(契约 1 端到端面——不新起跑批)---- + # 两个平行 core(同 db/同一 BoardData 产物/同初始展开),一侧挂生产默认开态 view、一侧挂 + # 显式 off 态 view(差异唯一定位在类型微光开关——与渲染取证双帧同款隔离思想);同一动作 + # 序列逐步对照摘要,on 侧若在微光派生中意外写板态,两侧即分叉。 + var aeA = load("res://core/action_economy.gd").new(_db) + var msA = load("res://core/minesweeper_rules.gd").new(_db, aeA) + var aeB = load("res://core/action_economy.gd").new(_db) + var msB = load("res://core/minesweeper_rules.gd").new(_db, aeB) + msA.install_board(b) + msB.install_board(b) + aeA.notify_round_start() + aeB.notify_round_start() + var viewA = load("res://presentation/views/board_view.gd").new() + viewA.set_identity_resolver(_glow_fixture_identity) + viewA.rebuild(b, msA) + viewA.set_dig_glow_enabled(true) + var viewB = load("res://presentation/views/board_view.gd").new() + viewB.set_identity_resolver(_glow_fixture_identity) + viewB.rebuild(b, msB) + viewB.set_dig_glow_enabled(true) + viewB.set_type_glow_enabled(false) # off 态 = 基线保护面 + var diverge := "" + var seq_steps := 0 + while seq_steps < 12: + var act7: Dictionary = AutoPlayer.novice_action(msA) + if String(act7.get("kind", "stop")) == "stop": + break + var op7 := String(act7.get("kind", "")) + if op7 == "seal": + op7 = "seal_place" + var batch7 := [{"action": op7, "cell": act7.get("cell")}] + msA.settle_batch(batch7) + msB.settle_batch(batch7) + viewA.refresh() + viewB.refresh() + if _ms_digest(msA, aeA) != _ms_digest(msB, aeB): + diverge = "第 %d 步(%s)后摘要分叉" % [seq_steps, op7] + break + seq_steps += 1 + Kit.check("契约 1 同板同序列 on/off 两态结算逐位一致(%d 步对照——A 开态/B 关态)%s" + % [seq_steps, diverge], + diverge == "") + Kit.check("契约 1 对照前提:同序列 ≥5 步(实测 %d)" % seq_steps, seq_steps >= 5) + viewA.free() + viewB.free() + + +# ---- 战斗日志视图·行格式化呈现层同步闸(真人试玩卡断 bug 回归: +# 伤害行 attach 字段变更后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach, +# _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕, +# 本段把现行行形态逐型喂进真实视图:拆行残留与行字段错位在此转红)---- +func _smoke_battle_log_view() -> void: + print("--- 战斗日志视图行格式化(行协议 9 类呈现层同步闸)---") + var view = load("res://presentation/views/battle_log_view.gd").new(_db) + # 敌方行动伤害行(现行形态:baseline 在场、attach 已出协议——修复前此行即 Invalid access) + view.append_row({"type": "damage", "display_name": "地精 撕咬", "value": 2, "target": "主角", + "baseline": 3, "roll": 15, "ac": 12, "tier": 100, + "source_class": "enemy_action", "round_index": 2}) + var enemy_txt: String = view.parsed_text() + Kit.check("敌方伤害行格式化(baseline 行不读退役 attach)", + enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12")) + Kit.check("退役伏击/基线拆行文案不再出现", + not enemy_txt.contains("伏击") and not enemy_txt.contains("基线")) + # 主角攻击行·单分量模型(payload 只带实际攻击类型一键——pierce/magic 多键即崩) + view.append_row({"type": "damage", "display_name": "主角攻击", "value": 4, "pierce": 4, + "roll": 20, "ac": 13, "tier": 100, "crit": true, "killed": true, + "source_class": "hero_attack", "round_index": 2}) + var hero_txt: String = view.parsed_text() + Kit.check("主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)", + hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■")) + Kit.check("退役三分量拆行文案不再出现", not hero_txt.contains(" + 穿刺 ")) + # 未生效行聚合(pattern 3 聚合纪律——同键两行呈现 ×2 不逐行刷屏) + view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1}) + view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1}) + view.append_row({"type": "heal", "display_name": "急救包", "value": 3, "target": "主角", "source_class": "safe_cell", "round_index": 1}) + var agg_txt: String = view.parsed_text() + Kit.check("未生效行聚合计数 ×2 呈现", agg_txt.contains("⚠") and agg_txt.contains("×2")) + # 其余行型逐型喂入:格式化中途夭折 = 解析文本零增长(脚本错误静默吞断言的盲区由此转为可见 FAIL) + var rest := { + "effect": {"type": "effect", "display_name": "攻击强化", "value": 1, "pool_value": 2, "effect_id": "attack_up", "source_class": "seal_hit", "round_index": 1}, + "seal_miss": {"type": "seal_miss", "display_name": "封印未中", "source_class": "seal_miss", "round_index": 1}, + "cast": {"type": "cast", "display_name": "卓尔精灵 施放 毒雾", "source_class": "enemy_action", "round_index": 3}, + "decay": {"type": "decay", "value": 1, "round_index": 3}, + "verdict": {"type": "verdict", "display_name": "下一轮次", "value": 0, "round_index": 3}, + "highlight": {"type": "highlight", "display_name": "完美封印!清图奖励 ×2", "value": 2, "source_class": "clear_reward", "round_index": 4}, + } + for row_type in rest: + var before: int = view.parsed_text().length() + view.append_row(rest[row_type]) + Kit.check("行型 %s 格式化落地(解析文本增长)" % String(row_type), + view.parsed_text().length() > before) + + +# ---- UI 全局主题冒烟(需求卡片 §7 验收清单 V1-V6)---- +# V1 挂载覆盖 / V2 滑轨两段 / V3 预盖件运行时证词 / V4 旧形态零残留 / +# V5 回归(本冒烟全绿即回归网)/ V6 中文字体防御(挂主题后 default_font 顶掉 custom_font 也不丢字形) +func _smoke_global_theme() -> void: + print("--- UI 全局主题(main_theme.tres 挂载与词条断言)---") + var th := ThemeDB.get_project_theme() + Kit.check("V1 项目主题挂载(project.godot gui/theme/custom → main_theme.tres)", th != null) + if th == null: + return + # V1:类型集覆盖 §4「做」清单全部类型(含 PopupMenu 弹层预盖) + var need_types := ["VScrollBar", "HScrollBar", "ScrollContainer", "HSlider", "VSlider", + "TooltipPanel", "TooltipLabel", "LineEdit", "SpinBox", "HSeparator", "VSeparator", + "CheckBox", "CheckButton", "OptionButton", "PopupMenu"] + var type_set := th.get_type_list() + for t in need_types: + Kit.check("V1 主题类型覆盖 %s" % t, type_set.has(t)) + # V1 红线:Button 类型词条零接触(五态矩阵归 06 第一层第二条) + Kit.check("V1 红线 Button 类型词条零接触", not type_set.has("Button")) + # V1 红线:focus 族词条一律零定义(扫本主题自有条目,不走默认主题链) + var focus_hits := "" + for t in type_set: + for n in (th.get_stylebox_list(t) + th.get_icon_list(t) + th.get_color_list(t) + + th.get_font_list(t) + th.get_font_size_list(t) + th.get_constant_list(t)): + if "focus" in String(n): + focus_hits += "%s/%s " % [t, n] + Kit.check("V1 红线 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + focus_hits + ")"), + focus_hits == "") + # V1:关键词条抽查在位(实例走主题链解析) + var vsb := VScrollBar.new() + Kit.check("V1 滚动条槽/柄三态词条在位", + vsb.has_theme_stylebox("scroll") and vsb.has_theme_stylebox("grabber") + and vsb.has_theme_stylebox("grabber_highlight") and vsb.has_theme_stylebox("grabber_pressed")) + Kit.check("V1 滚动条 scroll_focus 未定义(红线)", + not th.has_stylebox("scroll_focus", "VScrollBar") and not th.has_stylebox("scroll_focus", "HScrollBar")) + var hsl := HSlider.new() + Kit.check("V1 滑轨槽/填充/柄词条在位", + hsl.has_theme_stylebox("slider") and hsl.has_theme_stylebox("grabber_area") + and hsl.has_theme_stylebox("grabber_area_highlight") + and hsl.has_theme_icon("grabber") and hsl.has_theme_icon("grabber_highlight") + and hsl.has_theme_icon("grabber_disabled")) + var le := LineEdit.new() + Kit.check("V1 LineEdit 词条在位(含占位色 font_placeholder_color)", + le.has_theme_stylebox("normal") and le.has_theme_color("font_color") + and le.has_theme_color("font_placeholder_color")) + var sp := SpinBox.new() + Kit.check("V1 SpinBox 箭头词条在位(updown 合并槽)", sp.has_theme_icon("updown")) + var hsep := HSeparator.new() + var vsep := VSeparator.new() + Kit.check("V1 分隔线词条在位", + hsep.has_theme_stylebox("separator") and vsep.has_theme_stylebox("separator")) + var tpanel := PanelContainer.new() + var tlabel := Label.new() + Kit.check("V1 TooltipPanel 词条在位", tpanel.has_theme_stylebox("panel")) + Kit.check("V1 TooltipLabel 词条在位", + tlabel.has_theme_color("font_color") and tlabel.has_theme_font_size("font_size")) + # V2:滑轨修复——已填充段 BG_ROOT 对槽暖石 ≈5.5:1(冻结值锚定) + var fill_sb: StyleBox = hsl.get_theme_stylebox("grabber_area") + var track_sb: StyleBox = hsl.get_theme_stylebox("slider") + if fill_sb is StyleBoxFlat and track_sb is StyleBoxFlat: + var fill_c: Color = (fill_sb as StyleBoxFlat).bg_color + var track_c: Color = (track_sb as StyleBoxFlat).bg_color + Kit.check("V2 冻结值锚定(已填充段=BG_ROOT #3A2E22,槽=暖石 #B8A582)", + fill_c.is_equal_approx(Color8(58, 46, 34)) and track_c.is_equal_approx(Color8(184, 165, 130))) + var ratio := _theme_contrast(fill_c, track_c) + Kit.check("V2 滑轨两段对比 %.2f:1 ≥4.5:1(拖到中点一眼可辨)" % ratio, ratio >= 4.5) + else: + Kit.check("V2 滑轨 stylebox 类型均为 StyleBoxFlat", false) + # V3:预盖件运行时证词(临时实例化读词条——无实役控件的在役证据) + var cbx := CheckBox.new() + Kit.check("V3 CheckBox 四态图标非空", + cbx.get_theme_icon("checked") != null and cbx.get_theme_icon("unchecked") != null + and cbx.get_theme_icon("checked_disabled") != null and cbx.get_theme_icon("unchecked_disabled") != null) + var cbt := CheckButton.new() + Kit.check("V3 CheckButton 四态图标非空(与 CheckBox 同套 checked/unchecked 名,4.7 源码实证)", + cbt.get_theme_icon("checked") != null and cbt.get_theme_icon("unchecked") != null + and cbt.get_theme_icon("checked_disabled") != null and cbt.get_theme_icon("unchecked_disabled") != null) + var pm := PopupMenu.new() + Kit.check("V3 PopupMenu 词条存在(弹层预盖)", + pm.has_theme_stylebox("panel") and pm.has_theme_stylebox("hover") + and pm.has_theme_font_size("font_size")) + var ob := OptionButton.new() + Kit.check("V3 OptionButton 词条存在(预盖)", + ob.has_theme_stylebox("normal") and ob.has_theme_stylebox("hover") + and ob.has_theme_stylebox("pressed") and ob.has_theme_color("font_color")) + # V4:旧形态源码零残留(剥注释行后扫;ui_kit 两函数+挂载行+滑轨工厂) + for path in ["res://presentation/views/ui_kit.gd", "res://presentation/ui_assembler.gd", + "res://presentation/views/settings_overlay.gd", "res://presentation/views/debug_overlay.gd"]: + var src: String = FileAccess.get_file_as_string(path) + var bad := "" + for ln in src.split("\n", false): + var s := ln.strip_edges() + if s.begins_with("#"): + continue + if "styled_slider" in s or "tooltip_theme" in s or "_tip_theme" in s or "overlay.theme" in s: + bad += s + " " + Kit.check("V4 退役实现零残留(%s)%s" % [path, "" if bad == "" else "违例:" + bad], + bad == "") + # V6:中文字体防御——default_font 非空且 Label 实取字体非空 + Kit.check("V6 主题 default_font 非空(挂主题顶掉 custom_font 也不丢中文字形)", th.default_font != null) + var lbl := Label.new() + lbl.text = "测试" + Kit.check("V6 Label 实取字体非空(中文字形防御实证)", lbl.get_theme_font("font") != null) + vsb.free(); hsl.free(); le.free(); sp.free(); hsep.free(); vsep.free() + tpanel.free(); tlabel.free(); cbx.free(); cbt.free(); pm.free(); ob.free(); lbl.free() + + +# ---- 按钮五态矩阵冒烟(四工厂产物逐实例 override 断言;V1/V2)---- +func _smoke_button_five_states() -> void: + print("--- UI 按钮五态矩阵(四工厂五态样式盒)---") + var uikit_s = load("res://presentation/views/ui_kit.gd") + var states := ["normal", "hover", "pressed", "disabled", "focus"] + # 普通钮(verb_button 同走 button() 打底——同断言覆盖) + var b = uikit_s.button("测试") + for st in states: + Kit.check("V1 普通钮 %s 样式盒非空" % st, b.has_theme_stylebox_override(st)) + var n_sb: StyleBox = b.get_theme_stylebox("normal") + var h_sb: StyleBox = b.get_theme_stylebox("hover") + var p_sb: StyleBox = b.get_theme_stylebox("pressed") + var d_sb: StyleBox = b.get_theme_stylebox("disabled") + var f_sb: StyleBox = b.get_theme_stylebox("focus") + Kit.check("V1 普通钮 normal/hover/disabled 均为石纹九宫格贴图样式盒", + n_sb is StyleBoxTexture and h_sb is StyleBoxTexture and d_sb is StyleBoxTexture) + # 悬停=乙案:modulate 提亮(≠常态 WHITE——乙案判据) + var h_mod: Color = (h_sb as StyleBoxTexture).modulate_color + Kit.check("V1 普通钮 hover=modulate 提亮(乙案,异于常态 WHITE)", + h_mod != Color.WHITE and h_mod.r > 1.0) + # 禁用=甲案:本族底+α0.45 幽灵化 + var d_mod: Color = (d_sb as StyleBoxTexture).modulate_color + Kit.check("V1 普通钮 disabled=本族底 α0.45 幽灵化(甲案)", + is_equal_approx(d_mod.a, 0.45) and d_mod.r == 1.0 and d_mod.g == 1.0) + # 错配修正判据:disabled≠pressed(旧「禁用=按压图」) + Kit.check("V1 普通钮 disabled≠pressed(旧错配已修正)", _sb_content_differs(d_sb, p_sb)) + # 焦点=甲案:INK 2px 内缩 1px 环、无底(叠加态不遮钮底) + Kit.check("V1 普通钮 focus 样式盒为扁平环(StyleBoxFlat)", f_sb is StyleBoxFlat) + var fsb := f_sb as StyleBoxFlat + Kit.check("V1 焦点环色=INK(与 font_focus_color 同源)", fsb.border_color == uikit_s.INK) + Kit.check("V1 焦点环宽=2px", + is_equal_approx(fsb.border_width_left, 2.0) and is_equal_approx(fsb.border_width_right, 2.0) + and is_equal_approx(fsb.border_width_top, 2.0) and is_equal_approx(fsb.border_width_bottom, 2.0)) + Kit.check("V1 焦点环内缩=1px", + is_equal_approx(fsb.expand_margin_left, -1.0) and is_equal_approx(fsb.expand_margin_top, -1.0) + and is_equal_approx(fsb.expand_margin_right, -1.0) and is_equal_approx(fsb.expand_margin_bottom, -1.0)) + Kit.check("V1 焦点环无底(叠加态不遮钮底)", not fsb.draw_center and fsb.bg_color.a < 0.001) + b.free() + # 主钮:五态非空+本族平涂(pressed/disabled 非石纹)+disabled≠pressed+hover 提亮档承现行 + var pb = uikit_s.primary_button("测试") + for st in states: + Kit.check("V1 主钮 %s 样式盒非空" % st, pb.has_theme_stylebox_override(st)) + var ppn: StyleBox = pb.get_theme_stylebox("pressed") + var pdd: StyleBox = pb.get_theme_stylebox("disabled") + var phv: StyleBox = pb.get_theme_stylebox("hover") + Kit.check("V1 主钮 pressed/disabled=本族平涂(非石纹)", + ppn is StyleBoxFlat and pdd is StyleBoxFlat) + Kit.check("V1 主钮 pressed=烛光黄深档(现行值不动)", + (ppn as StyleBoxFlat).bg_color == Color8(214, 172, 88)) + Kit.check("V1 主钮 disabled=本族底 α0.45 幽灵化", + is_equal_approx((pdd as StyleBoxFlat).bg_color.a, 0.45)) + Kit.check("V1 主钮 disabled≠pressed", _sb_content_differs(ppn, pdd)) + Kit.check("V1 主钮 hover=提亮档(承现行不动)", + (phv as StyleBoxFlat).bg_color == uikit_s.CANDLE.lightened(0.06)) + pb.free() + # 危险钮:补本族 pressed(深档)+disabled 幽灵化+focus;pressed/disabled 非石纹 + var db = uikit_s.danger_button("测试") + for st in states: + Kit.check("V1 危险钮 %s 样式盒非空" % st, db.has_theme_stylebox_override(st)) + var dpr: StyleBox = db.get_theme_stylebox("pressed") + var dds: StyleBox = db.get_theme_stylebox("disabled") + Kit.check("V1 危险钮 pressed/disabled=本族平涂(非石纹,旧错配已修正)", + dpr is StyleBoxFlat and dds is StyleBoxFlat) + Kit.check("V1 危险钮 pressed=深红深档(暗于常态)", + (dpr as StyleBoxFlat).bg_color.r < 180.0 / 255.0) + Kit.check("V1 危险钮 disabled=本族底 α0.45 幽灵化", + is_equal_approx((dds as StyleBoxFlat).bg_color.a, 0.45)) + Kit.check("V1 危险钮 disabled≠pressed", _sb_content_differs(dpr, dds)) + db.free() + # V2 主题红线维持(Button 类型词条零接触+focus 族词条零定义)——与 _smoke_global_theme 同判据复述 + var th := ThemeDB.get_project_theme() + if th != null: + Kit.check("V2 红线维持 Button 类型词条零接触", not th.get_type_list().has("Button")) + var focus_hits := "" + for t in th.get_type_list(): + for nm in (th.get_stylebox_list(t) + th.get_icon_list(t) + th.get_color_list(t) + + th.get_font_list(t) + th.get_font_size_list(t) + th.get_constant_list(t)): + if "focus" in String(nm): + focus_hits += "%s/%s " % [t, nm] + Kit.check("V2 红线维持 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + focus_hits + ")"), + focus_hits == "") + + +# ---- 全局纹理过滤冒烟(工程设置 Nearest+平滑渐变件 LINEAR 白名单;V3)---- +func _smoke_texture_filter() -> void: + print("--- UI 全局纹理过滤(Nearest+白名单三件)---") + var v = ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_filter") + Kit.check("V3 工程设置 default_texture_filter=0(Nearest,翻转面=立绘/瓦片/九宫格 crisp)", + v != null and int(v) == 0) + var uikit_s = load("res://presentation/views/ui_kit.gd") + var vg = uikit_s.vignette_rect() + Kit.check("V3 白名单在位:暗角 vignette 显式 LINEAR", + vg.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR) + vg.free() + var bm = uikit_s.beams_rect() + Kit.check("V3 白名单在位:光柱 beams 显式 LINEAR", + bm.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR) + # T4 光柱连续 alpha 结构断言(观感归目检):带心峰值 0.05/带缘连续过渡/纵向衰减上亮下暗 + var bimg: Image = bm.texture.get_image() + var max_a := 0.0 + var mid_a := 0 + var top_sum := 0.0 + var bot_sum := 0.0 + for y in 560: + for x in 560: + var pa := bimg.get_pixel(x, y).a + max_a = maxf(max_a, pa) + if pa > 0.001 and pa < 0.049: + mid_a += 1 + if y < 280: + top_sum += pa + else: + bot_sum += pa + Kit.check("T4 光柱带心峰值 α≈0.05(8bit 截断量化下限 12/255,实测 %.5f)" % max_a, + max_a >= 12.0 / 255.0 and max_a <= 0.05) + Kit.check("T4 光柱带内连续 alpha 过渡像素 %d 个(旧零过渡硬边已修)" % mid_a, mid_a > 100) + Kit.check("T4 光柱纵向衰减生效(上半亮度 %.0f > 下半 %.0f 的 2 倍——上亮下暗)" % [top_sum, bot_sum], + top_sum > bot_sum * 2.0) + bm.free() + var gb = uikit_s.gradient_base(Color.BLACK, Color.WHITE) + Kit.check("V3 白名单在位:地图渐变 gradient_base 显式 LINEAR", + gb.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR) + gb.free() + # 既有显式 LINEAR 两处不丢(源码锚定——脚影/状态图标) + var bw_src: String = FileAccess.get_file_as_string("res://presentation/views/board_world.gd") + Kit.check("V3 脚影显式 LINEAR 不丢(board_world.gd)", + bw_src.contains("shadow.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR")) + var ss_src: String = FileAccess.get_file_as_string("res://presentation/views/status_slot_row.gd") + Kit.check("V3 状态图标显式 LINEAR 不丢(status_slot_row.gd)", + ss_src.contains("icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR")) + + +# ---- <4.5:1 文字位换墨冒烟(站桩徽标两族亮档+RAP 行深石墨;V4)---- +func _smoke_contrast_inks() -> void: + print("--- UI 残余 <4.5:1 文字位换墨(V4 对比度锚定)---") + var uikit_s = load("res://presentation/views/ui_kit.gd") + var stage: Color = uikit_s.STAGE_BG + var r_order := _theme_contrast(uikit_s.GOLD_BRIGHT, stage) + Kit.check("V4 顺位徽标/重甲徽标 GOLD_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" % r_order, r_order >= 4.5) + var r_ward := _theme_contrast(uikit_s.HERO_BLUE_BRIGHT, stage) + Kit.check("V4 魔法护甲徽标 HERO_BLUE_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" % r_ward, r_ward >= 4.5) + var r_rap := _theme_contrast(uikit_s.STONE_DEEP, uikit_s.PANEL_DATA) + Kit.check("V4 RAP 反馈行 STONE_DEEP 对数据板 %.2f:1 ≥4.5:1" % r_rap, r_rap >= 4.5) + # 消费点源码锚定:新墨在位、旧裸用点清零、语义岗常量不动 + var sb_src: String = FileAccess.get_file_as_string("res://presentation/views/standee_body.gd") + Kit.check("V4 顺位徽标①②③ 墨=GOLD_BRIGHT(13px 行原位)", + sb_src.contains("HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_BRIGHT")) + Kit.check("V4 魔法护甲徽标墨=HERO_BLUE_BRIGHT", sb_src.contains("col = UiKit.HERO_BLUE_BRIGHT")) + Kit.check("V4 重甲徽标墨=GOLD_BRIGHT", sb_src.contains("col = UiKit.GOLD_BRIGHT")) + Kit.check("V4 GOLD_DEEP 语义岗不动(standee:199 意图动画判据)", + sb_src.contains("if _intent_col == UiKit.GOLD_DEEP:")) + var ap_src: String = FileAccess.get_file_as_string("res://presentation/views/ap_bar_view.gd") + Kit.check("V4 RAP 反馈行墨=STONE_DEEP(原位行锚定)", + ap_src.contains('UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)')) + Kit.check("V4 RAP 反馈行旧墨 STONE_FAINT 清零(含注记面)", not ap_src.contains("STONE_FAINT")) + + +# ---- 元素细节余②③冒烟(BCard 图标槽明度差降档+商店居中+营地四栏等高)---- +func _smoke_element_details() -> void: + print("--- UI 元素细节余②③(图标槽降档+布局修)---") + var uikit_s = load("res://presentation/views/ui_kit.gd") + # ②白图标 ≥3:1 图形对比硬约束+降档方向锚定 + var r_white := _theme_contrast(Color.WHITE, uikit_s.SLOT_BACKDROP_SOFT) + Kit.check("T5② BCard 图标槽白图标对比 %.2f:1 ≥3:1(硬约束)" % r_white, r_white >= 3.0) + var r_new := _theme_contrast(uikit_s.SLOT_BACKDROP_SOFT, uikit_s.PANEL_DATA) + var r_old := _theme_contrast(uikit_s.SLOT_BACKDROP, uikit_s.PANEL_DATA) + Kit.check("T5② 明度差降档生效(槽对卡面 %.2f:1 < 旧 %.2f:1)" % [r_new, r_old], r_new < r_old) + var uk_src: String = FileAccess.get_file_as_string("res://presentation/views/ui_kit.gd") + Kit.check("T5② BCard 图标槽落柔和档(SLOT_BACKDROP_SOFT)", + uk_src.contains("UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)")) + var ss_src: String = FileAccess.get_file_as_string("res://presentation/views/status_slot_row.gd") + Kit.check("T5② B 区状态图标槽仍用深档(SLOT_BACKDROP,未误伤)", + ss_src.contains("UiKit.SLOT_BACKDROP")) + # ③布局修源码锚定 + var shop_src: String = FileAccess.get_file_as_string("res://presentation/views/shop_overlay_view.gd") + Kit.check("T5③ 商店卡片区垂直居中(ALIGNMENT_CENTER)", + shop_src.contains("_cards_box.alignment = BoxContainer.ALIGNMENT_CENTER")) + var camp_src: String = FileAccess.get_file_as_string("res://presentation/views/camp_overlay_view.gd") + var camp_min := camp_src.count("custom_minimum_size = Vector2(0, 336)") + Kit.check("T5③ 营地三 holder 统一 min 高 336(实测 %d 处)" % camp_min, camp_min == 3) + var camp_fill := camp_src.count("size_flags_vertical = Control.SIZE_FILL") + Kit.check("T5③ 营地四栏面板纵向 FILL 等高(实测 %d 处)" % camp_fill, camp_fill == 4) + Kit.check("T5③ 三处锁文垂直居中在位", camp_src.count("SIZE_SHRINK_CENTER") >= 3) + + +# ---- 诊断转储钮冒烟(角落钮不在玩家画面+转储入口在调试浮窗;V6)---- +func _smoke_diag_button_retired() -> void: + print("--- UI 诊断转储钮退役(V6:玩家画面清零+浮窗可达)---") + var main_src: String = FileAccess.get_file_as_string("res://presentation/main.gd") + Kit.check("V6 主区无 build_diag_button 调用残留(旧实现同批清理)", + not main_src.contains("build_diag_button")) + Kit.check("V6 转储入口连接随迁(组合根接浮窗 diagnose_requested 信号)", + main_src.contains("debug_overlay.diagnose_requested.connect(_on_diagnose_pressed)")) + Kit.check("V6 转储编排承接函数在位(dump_diagnosis 卡死取证保留)", + main_src.contains("func _on_diagnose_pressed()")) + var asm_src: String = FileAccess.get_file_as_string("res://presentation/ui_assembler.gd") + Kit.check("V6 build_diag_button 工厂已退役(ui_assembler 零残留)", + not asm_src.contains("build_diag_button")) + var dbg_src: String = FileAccess.get_file_as_string("res://presentation/views/debug_overlay.gd") + Kit.check("V6 调试浮窗 diagnose_requested 信号在位", dbg_src.contains("signal diagnose_requested")) + Kit.check("V6 浮窗转储钮外发方法在位(纯视图边界)", dbg_src.contains("_emit_diagnose_requested")) + Kit.check("V6 浮窗诊断转储钮在位(玩家画面角落钮清零后唯一入口)", + dbg_src.contains('UiKit.button("转储")')) + + +# ---- 状态面板只读投影一致性(R4 数据只读同源:状态面板展示值逐项 == MetaProgression +# 访问器读数;投影拼装与 main._status_data 同构(同访问器族同序)。只读性连接巡检与零动效 +# 断言归 ui_struct_probe 呈现侧,本段只管数据面)---- +func _status_panel_projection() -> void: + print("--- 状态面板·只读投影一致性(隔离档 smoke_meta_buy 复用)---") + _meta_smoke_cleanup() + var sv = _meta_new_save(_META_SMOKE_PATHS[0]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + # 已购态夹具:余额非零 + 秘宝匣首件解锁——投影可辨非全零白板 + sv.add_keystone(37) + sv.unlock_item("morning_order") + sv.write_to_disk() + # 投影拼装(main._status_data 同构)+ 视图喂入 + var data := {"balance": mp.keystone_balance(), "workshop": mp.treasure_shop_status(), + "tree": mp.tree_camp_rows(), "fork_groups": mp.tree_fork_groups()} + var po = load("res://presentation/views/status_panel.gd").new() + po.open_status(data) + Kit.check("状态面板开层可见(open_status 直开——P4 甲零动效路径)", po.visible) + Kit.check("余额徽投影 = keystone_balance() 读数(%s)" % po._balance_badge.value_label.text, + String(po._balance_badge.value_label.text) == "钥石 %d" % mp.keystone_balance()) + var ws: Array = mp.treasure_shop_status() + var ws_ok: bool = po._workshop_holder.get_child_count() == ws.size() + var uikit_s = load("res://presentation/views/ui_kit.gd") + for i in mini(ws.size(), po._workshop_holder.get_child_count()): + var row: Dictionary = ws[i] + var lbls2: Array = [] + for ch in po._workshop_holder.get_child(i).get_children(): + if ch is Label: + lbls2.append(ch) + var unlocked: bool = bool(row.get("unlocked", false)) + var expect_prog := "已入手" + if not unlocked: + if String(row.get("mode", "")) == "keystone": + expect_prog = "钥石 %d" % int(row.get("price", 0)) + elif String(row.get("mode", "")) == "condition": + var parts: Array[String] = [] + for req_v2 in (row.get("reqs", []) as Array): + var req2: Dictionary = req_v2 as Dictionary + var lbl2 := String(uikit_s.TREASURE_REQ_LABELS.get(String(req2.get("key", "")), "条件")) + parts.append("%s %d/%d" % [lbl2, int(req2.get("progress", 0)), int(req2.get("threshold", 0))]) + expect_prog = "、".join(parts) + var expect_st2 := "已解锁" if unlocked else "未解锁" + ws_ok = ws_ok and lbls2.size() == 3 \ + and String(lbls2[0].text) == String(row.get("display_name", "")) \ + and String(lbls2[1].text) == expect_prog \ + and String(lbls2[2].text) == expect_st2 + Kit.check("秘宝匣 12 行投影 = treasure_shop_status() 逐项(名称/钥石价与条件进度/解锁态——首件解锁夹具在列)〔秘宝批 V19 呈现数据源〕", + ws_ok) + var owned := 0 + for trow in (mp.tree_camp_rows() as Array): + if int(trow.get("level", 0)) > 0: + owned += 1 + var tree_txt := String(po._tree_label.text) + Kit.check("天赋树概览聚合文案与已购节点数一致(零已购=空态句)", + tree_txt == "尚未购买任何节点(天赋树在营地主柜台)" or tree_txt.begins_with("已购 %d 项:" % owned)) + po.visible = false + po.free() + + +# ---- 生涯页只读投影一致性(汇总段六类九项逐行 == SaveManager 计数访问器/ +# MetaProgression 访问器读数;投影拼装与 main._career_data 同构(同访问器族同序)。 +# 只读性连接巡检与零动效断言归 ui_struct_probe 呈现侧,本段只管数据面+旧档载入显示 +# (v5 样档升 v6——完成定义 6「计数域缺省补零、页正常显示」))---- +func _career_overlay_projection() -> void: + print("--- 生涯页·只读投影一致性(隔离档 smoke_meta_career/career_v5)---") + var sv = _meta_new_save(_META_SMOKE_PATHS[9]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + # 已玩态夹具:两局收束(一通一止)+ 钥石非零——投影可辨非全零白板 + # (生涯页不统计天赋树与账号成长态,只统计终身计数) + sv.add_keystone(45) + sv.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + "stopped_at": 0, "keystone_total": 0, "master_seed": 21}) + sv.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 0, "master_seed": 22}) + sv.write_to_disk() + # 投影拼装(main._career_data 同构)+ 视图喂入 + var data := {"total": sv.adventure_total(), "cleared": sv.adventure_cleared(), + "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), + "boss_kills": sv.boss_kill_count(), "keystone": mp.keystone_balance()} + for i in _db.level_count(): + var li: int = i + 1 + data["clears"].append({"display_name": String(_db.level_row(li).get("display_name", "")), + "count": sv.clear_count(li)}) + if sv.boss_first_kill_marked(li): + data["boss_first_kills"] += 1 + var co = load("res://presentation/views/career_overlay.gd").new() + co.open_career(data) + Kit.check("生涯页开层可见(open_career 直开——零动效路径)", co.visible) + Kit.check("总局数投影 = adventure_total() 读数(%s)" % co._total_label.text, + String(co._total_label.text) == "2 局") + Kit.check("通关局数投影 = adventure_cleared() 读数(%s)" % co._cleared_label.text, + String(co._cleared_label.text) == "1 局(三关全通)") + var expect_clears: Array[String] = [] + for row in (data["clears"] as Array): + expect_clears.append("%s ×%d" % [String(row.get("display_name", "")), int(row.get("count", 0))]) + Kit.check("各关通关次数投影 = clear_count() 逐关读数(%s)" % co._clears_label.text, + String(co._clears_label.text) == " · ".join(expect_clears)) + Kit.check("Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" % [co._boss_first_label.text, co._boss_kills_label.text], + String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次") + Kit.check("钥石余额徽投影 = keystone_balance() 读数(%s)" % co._keystone_badge.value_label.text, + String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance()) + co.visible = false + co.free() + # 旧档载入显示(完成定义 6):v5 现役档形态(计数域两键不存在)升 v6 → 生涯页正常呈现缺省值 + var v5 = load("res://core/save_manager.gd").new(_META_SMOKE_PATHS[10]) + var fv5c := FileAccess.open(_META_SMOKE_PATHS[10], FileAccess.WRITE) + fv5c.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 3}})) + fv5c.close() + Kit.check("v5 现役档升 v6 载入成功(计数域缺省补零)", + v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0) + var mp_v5 = load("res://core/meta_progression.gd").new(_db, v5) + var data_v5 := {"total": v5.adventure_total(), "cleared": v5.adventure_cleared(), + "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), + "boss_kills": v5.boss_kill_count(), "keystone": mp_v5.keystone_balance()} + for i in _db.level_count(): + data_v5["clears"].append({"display_name": String(_db.level_row(i + 1).get("display_name", "")), + "count": v5.clear_count(i + 1)}) + var co_v5 = load("res://presentation/views/career_overlay.gd").new() + co_v5.open_career(data_v5) + Kit.check("旧档载入后生涯页正常显示(v5 升 v6:总局数/通关局数显示 0,既有钥石余额原样呈现)", + co_v5.visible and String(co_v5._total_label.text) == "0 局" \ + and String(co_v5._cleared_label.text) == "0 局(三关全通)" \ + and String(co_v5._keystone_badge.value_label.text) == "钥石 3") + co_v5.visible = false + co_v5.free() + + +# 内容级比较(核代码增补——对象引用比较恒真不设防):同型比内容(Texture 比纹理、Flat 比底色),异型即不同 +func _sb_content_differs(a: StyleBox, b: StyleBox) -> bool: + if a is StyleBoxTexture and b is StyleBoxTexture: + return (a as StyleBoxTexture).texture != (b as StyleBoxTexture).texture + if a is StyleBoxFlat and b is StyleBoxFlat: + return (a as StyleBoxFlat).bg_color != (b as StyleBoxFlat).bg_color + return a != b diff --git a/game/tests/验证集清单.md b/game/tests/验证集清单.md new file mode 100644 index 00000000..e5a54881 --- /dev/null +++ b/game/tests/验证集清单.md @@ -0,0 +1,1469 @@ +# 验证集清单(迁移参照物·冻结) + +> 生成日期:2026-10-22 · 基点 commit:`1aea93972b5ff3bc51de1814081e69a79f74a735` · 源:`smoke_all.gd` +> 提取口径:静态 `check(` 调用点,括号配平归集(可跨行)。基点实测调用点 1065 处; +> 行级 `grep -c "check("` = 1067 是另一口径——多出的 2 行为 `func check(` 定义行与 +> 一处注释行(「禁恒真 check(true)」),非调用点。运行时计数大于静态计数=循环展开条目, +> 两口径各自浮动,禁互相换算(tests/README「断言计数双口径」)。 +> 生成命令:`python tools/extract_checks.py --extract`(可复现;重新生成须 --force)。 +> 冻结纪律:每条的「标签原文」「cond 摘要」「quiet」三列为机器提取逐字保真,禁任何改写; +> cond 摘要=原文跨行折叠单行(连续空白并一空格),单元格内 `|`→`\|`、`\`→`\\` 转义,读回还原。 +> 「白话释义」「处置」两列由维护回填,不属冻结面。 +> 出口对账:`python tools/extract_checks.py --diff game/tests/验证集清单.md` +> ——标签/cond/计数三对齐才算迁移完(合同 §五出口机检)。 +> 维护义务:此后断言新增/删除/改判必须同步本清单(test-conventions §9.3 锚)。 + +## S01 _initialize(行 44–70 · 14 条 · 去向:单元测试 → tests/unit/data_config/rng_stream_boot_test.gd · 施工组 G12;去向补正:转办行→cross_system_flow_test.gd,见维护记录) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1 | 44 | "全部表加载 + 校验通过(含跨表护栏)" | load_ok | 全部数据表能加载且跨表校验零错误——表损坏或引用悬空在此红。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 2 | 53 | "统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)" | rng.derive_at(0) == 8672514112661456564 | 主种子 42 下统一流 scope 0 的派生种子必须等于此黄金值(与旧棋盘生成向量逐位连续的锚)。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 3 | 54 | "统一流向量 m=42 scope=1" | rng.derive_at(1) == 8834291164406037215 | 同上,scope 1 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 4 | 55 | "统一流向量 m=42 scope=2" | rng.derive_at(2) == 7714373262647006282 | 同上,scope 2 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 5 | 56 | "统一流向量 m=42 scope=3" | rng.derive_at(3) == 1280102845971471347 | 同上,scope 3 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 6 | 57 | "统一流向量 m=42 scope=4" | rng.derive_at(4) == 82585501446482399 | 同上,scope 4 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 7 | 58 | "统一流向量 m=42 scope=5" | rng.derive_at(5) == 8078924421474470922 | 同上,scope 5 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 8 | 59 | "统一流向量 m=42 scope=6" | rng.derive_at(6) == 6638052740430622424 | 同上,scope 6 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 9 | 60 | "统一流向量 m=42 scope=7" | rng.derive_at(7) == 8264196488417721294 | 同上,scope 7 黄金向量。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 10 | 61 | "derive_at 纯函数恒等" | rng.derive_at(0) == rng.derive_at(0) | derive_at 同参数两次调用结果恒等(纯函数、无隐藏状态)。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 11 | 65 | "open_stream 全局计数器单调 +1 ×2" | rng.counter() == 2 | 开两条流后全局取号计数器恰为 2(单调加一,不跳不漏)。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 12 | 66 | "首个取号 = scope 0(顺序确定)" | s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1) | 首个与次个 open_stream 依次取全局 scope 0、1 的派生种子(取号顺序确定)。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 13 | 69 | "new_adventure 重置全局计数器" | rng.counter() == 1 and s2.seed == rng.derive_at(0) | new_adventure 后计数器归 1 且新流首号=scope 0(会话重置语义)。 | 迁单元测试(tests/unit/data_config/rng_stream_boot_test.gd) | | +| 14 | 70 | "事件日志会话落盘(2026-09-27 统一流裁决)" | FileAccess.file_exists("user://logs/rng_log_4242.jsonl") | RNG 事件日志按会话落盘成文件(2026-09-27 统一流裁决的落地面)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | + +## S02 _split_tail(行 92–92 · 1 条 · 去向:系统测试 → tests/system_test.gd · 施工组 G1) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 15 | 92 | "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)" | false | autoload 链(ConfigDB/SaveManager)未就绪时必须显式红,禁静默跳过尾段。 | 迁系统测试(tests/system_test.gd) | | + +## S03 _smoke_type_gate(行 101–105 · 2 条 · 去向:单元测试 → tests/unit/contracts/source_guard_test.gd · 施工组 G12) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 16 | 101 | "V7 类型闸 warning 配置在位(untyped_declaration=1 显形)〔迭代27 T5〕" | pj.contains("gdscript/warnings/untyped_declaration=1") | project.godot 里 untyped_declaration=1 警告配置在位(类型闸的配置面源码断言)。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | +| 17 | 104 | "V7 类型审计器闸机在位(static_scan S9 → tools/audit_type_hints.py)〔迭代27 T5〕" | scan_src.contains("audit_type_hints.py") | 静态扫描脚本里类型审计器接线在座(audit_type_hints.py 被引用)。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | + +## S04 _smoke_hygiene_gates(行 132–153 · 6 条 · 去向:单元测试 → tests/unit/contracts/source_guard_test.gd · 施工组 G12) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 18 | 132 | "V8 字符串 Callable 产品码零命中(实测应 0——方法引用换装闸)〔迭代27 T6〕" | callables == 0 | core/presentation/data 三目录产品码零字符串形式 Callable(self,…)——方法引用必须换装。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | +| 19 | 134 | "V8 FeatsEngine 整文件退役(class_name 注销=文件删除)〔迭代27 T6〕" | not FileAccess.file_exists("res://core/feats_engine.gd") | core/feats_engine.gd 文件已删(FeatsEngine 退役=文件消失,不是注释掉)。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | +| 20 | 137 | "V8 死代码抽查·_attune_row 零残留〔迭代27 T6〕" | not bo.contains("func _attune_row") | 战斗编排器源码无 _attune_row 死函数残留。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | +| 21 | 140 | "V8 死代码抽查·attunement_counters 零残留〔迭代27 T6〕" | not sm.contains("func attunement_counters") | 存档管理器源码无 attunement_counters 死函数残留。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | +| 22 | 143 | "V8 GP 影子账残留零命中(main 源码面——文件已删+委托缝已拆)〔迭代27 T3〕" | not mv.contains("gp_shadow_ledger") and not mv.contains("_gp_ledger") | main 源码无 GP 影子账标识符残留(gp_shadow_ledger/_gp_ledger 双词面零命中)。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | +| 23 | 152 | "V1d 层序落位单口(_apply_layer_contract 之外 move_child 调用点=%d 应=0)〔装配生命批 V1〕" ↩ % outside_calls | outside_calls == 0 | main 的 move_child 调用全部收在 _apply_layer_contract 一个函数内(层序合同单口,函数外零调用)。 | 迁单元测试(tests/unit/contracts/source_guard_test.gd) | | + +## S05 _smoke_scene_split(行 162–214 · 11 条 · 去向:系统测试 → tests/system_test.gd · 施工组 G1) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 24 | 162 | "V2 菜单屏壳独立实例化(start_overlay+操作组三钮在位——主菜单三入口化)〔卡② mn1〕" | menu.start_overlay != null and menu.entersavebtn != null and menu.settingsbtn != null and menu.quitbtn != null | 菜单屏壳独立实例化后开始浮层与操作组六钮句柄齐备。 | 迁系统测试(tests/system_test.gd) | | +| 25 | 164 | "V2 菜单层默认可见(非模态全屏态)" | menu.start_overlay.visible | 开始层(菜单)默认可见(非模态全屏态)。 | 迁系统测试(tests/system_test.gd) | | +| 26 | 165 | "V2 屏壳根零鼠标截获(IGNORE——全屏 STOP 壳吃输入病灶回归闸)" | menu.mouse_filter == Control.MOUSE_FILTER_IGNORE | 菜单屏壳根不截获鼠标(IGNORE——全屏 STOP 壳吃输入的病灶回归闸)。 | 迁系统测试(tests/system_test.gd) | | +| 27 | 170 | "V2 营地屏壳独立实例化(camp_overlay 在位)" | camp.camp_overlay != null | 营地屏壳独立实例化营地浮层在位。 | 迁系统测试(tests/system_test.gd) | | +| 28 | 171 | "V2 营地层默认隐藏(模态纪律)" | not camp.camp_overlay.visible | 营地浮层默认隐藏(模态纪律)。 | 迁系统测试(tests/system_test.gd) | | +| 29 | 176 | "V2 冒险屏壳独立实例化(战斗宿主+四区句柄+关卡地图屏在位)" | adv.battle_root != null and adv.stage != null and adv.board_zone != null and adv.board_view != null and adv.ap_bar != null and adv.level_screen != null | 冒险屏壳独立实例化:战斗宿主、四区句柄、关卡地图屏在位。 | 迁系统测试(tests/system_test.gd) | | +| 30 | 187 | "V1a 启动态顶层可见层=start_overlay(实测顶层=%s——菜单被冒险屏盖住即红)〔装配生命批 V1〕" ↩ % (top.name if top != null else "null") | top == main._start_overlay | 启动态自树顶向下第一个可见内容层=开始浮层(菜单被冒险屏盖住即红)。 | 迁系统测试(tests/system_test.gd) | | +| 31 | 191 | "V1b 菜单屏壳在冒险屏壳之上(start_overlay 盖住战斗内容)〔装配生命批 V1〕" | main._menu_screen.get_index() > main._adventure_screen.get_index() | 菜单屏壳树序在冒险屏壳之上(开始层盖住战斗内容)。 | 迁系统测试(tests/system_test.gd) | | +| 32 | 209 | "V1c 合同树序链逐对保序(运行期树序==LAYER_CONTRACT 序;违例:%s)〔装配生命批 V1〕" ↩ % chain_detail | chain_ok | 运行期组合根树序与层序合同逐对保序(违例细节进标签)。 | 迁系统测试(tests/system_test.gd) | | +| 33 | 211 | "V2 组合根句柄指向屏壳成员(探针挂载假设 main.X 零迁移)" | main._start_overlay == main._menu_screen.start_overlay and main.camp_overlay == main._camp_screen.camp_overlay and main.level_screen == main._adventure_screen.level_screen and main._battle_root == main._adventure_screen.battle_root and main.board_view == main._adventure_screen.board_view | 组合根一级句柄指向屏壳成员(探针挂载假设 main.X 零迁移)。 | 迁系统测试(tests/system_test.gd) | | +| 34 | 213 | "V2 闲时会话自 _ready 即在(永非 null;迭代 27 T3——gp 影子账退役无账本成员)" | main.run_scope != null and not main.run_scope.is_adventure | 闲时会话自 _ready 即在、永非空且非冒险态。 | 迁系统测试(tests/system_test.gd) | | + +## S06 _smoke_boot_chain(行 250–307 · 15 条 · 去向:系统测试 → tests/system_test.gd · 施工组 G1) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 35 | 250 | "V2a ConfigDB 就绪且表已载(is_ready+hero 表在座)〔装配生命批 V2〕" | db.is_ready and db.hero != null | autoload ConfigDB 就绪且主角表已载。 | 迁系统测试(tests/system_test.gd) | | +| 36 | 255 | "V2b 设置已应用·音量(Master mute/volume_db=settings_store 现值 linear=%f)〔装配生命批 V2〕" ↩ % ss.volume_linear | AudioServer.is_bus_mute(master) == (ss.volume_linear <= 0.001) and is_equal_approx(AudioServer.get_bus_volume_db(master), linear_to_db(maxf(ss.volume_linear, 0.001))) | 引擎 Master 总线静音/音量与设置存储现值一致(真实设置文件已应用)。 | 迁系统测试(tests/system_test.gd) | | +| 37 | 259 | "V2b 设置已应用·减动效(UiKit.motion_scale=%s=settings_store.reduced_motion=%s)〔装配生命批 V2〕" ↩ % [ str(ui_kit.motion_scale), str(ss.reduced_motion)] | ui_kit.motion_scale == (0.5 if ss.reduced_motion else 1.0) | UiKit 减动效系数随设置 reduced_motion 取 0.5/1.0。 | 迁系统测试(tests/system_test.gd) | | +| 38 | 263 | "V2c 启动态 _start_overlay.visible=true(boot 后玩家见菜单)〔装配生命批 V2〕" | main._start_overlay.visible | 启动后开始层可见(玩家开机见菜单)。 | 迁系统测试(tests/system_test.gd) | | +| 39 | 269 | "V2d 续玩钮族退役·boot 无断点在档(启动态不隐式激活——卡① sv2 钉死;恒隐判据随钮退役由本断言+V2d 三钮在位承接)〔装配生命批 V2·卡② mn8 改写〕" ↩ | not sm.has_breakpoint() | boot 态无激活档:断点不在档且续玩钮恒隐藏。 | 迁系统测试(tests/system_test.gd) | | +| 40 | 273 | "V2-s1 boot 后无激活态(active_slot=0、写盘目标空)〔卡① sv2〕" | sm.active_slot() == 0 and not sm.has_write_target() | boot 后无激活态:激活槽 0、无写盘目标。 | 迁系统测试(tests/system_test.gd) | | +| 41 | 275 | "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕" | sm.keystone_balance() == 0 and not sm.has_breakpoint() and sm.adventure_total() == 0 | boot 内存为合法空态:钥石 0、无断点、总局数 0。 | 迁系统测试(tests/system_test.gd) | | +| 42 | 277 | "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕" | not sm.write_to_disk() | 无激活态调写盘被拒(生产入口返回 false)。 | 迁系统测试(tests/system_test.gd) | | +| 43 | 279 | "V2-s4 旧单档迁移不变式(旧档在则槽 1 必在——Q1 甲一次性迁移已发生)〔卡① sv12〕" | not (FileAccess.file_exists("user://save.json") and not FileAccess.file_exists(sm.slot_path(1))) | 旧单档迁移不变式:旧档存在则槽 1 档必存在(一次性迁移已发生)。 | 迁系统测试(tests/system_test.gd) | | +| 44 | 287 | "V2d 三钮在位·进入存档/设置/退出游戏(续玩钮族退役后主菜单操作组承接)〔卡② mn1/mn8〕" | main._menu_screen.entersavebtn != null and main._menu_screen.settingsbtn != null and main._menu_screen.quitbtn != null | 隔离档无断点时续玩钮隐藏(刷新单口与 boot 路径同源)。 | 迁系统测试(tests/system_test.gd) | | +| 45 | 292 | "V2d 四旧钮句柄零命中·%s(彻底撤出——禁隐藏态禁置灰态残留)〔卡② mn2〕" ↩ % old_btn | not (old_btn in main._menu_screen) | 设断点后续玩钮可见。 | 迁系统测试(tests/system_test.gd) | | +| 46 | 297 | "V2d 渲染面可见按钮计数==3(旧钮接回即红——渲染面锚,句柄缝盲区闭合)〔卡② mn1/mn2〕" | _count_visible_buttons(main._start_overlay) == 3 | 清断点后续玩钮回隐藏。 | 迁系统测试(tests/system_test.gd) | | +| 47 | 300 | "V2e run_scope 非空且为闲时会话(is_adventure=false+is_inside_tree)〔装配生命批 V2〕" | main.run_scope != null and not main.run_scope.is_adventure and main.run_scope.is_inside_tree() | 闲时会话 _ready 即建、在树、非冒险态(永非空)。 | 迁系统测试(tests/system_test.gd) | | +| 48 | 304 | "V2f 调试浮窗 boot 不自动开(菜单优先)〔2026-10-16 用户令〕" | not main.debug_overlay.visible | boot 不自动开调试浮窗(菜单优先)。 | 迁系统测试(tests/system_test.gd) | | +| 49 | 306 | "V2f 调试浮层角标 boot 可见(全局浮层入口在座)〔2026-10-16 用户令〕" | main.debug_badge != null and main.debug_badge.visible | 调试浮层入口角标 boot 可见。 | 迁系统测试(tests/system_test.gd) | | + +## S07 _smoke_lifecycle_matrix(行 374–483 · 10 条 · 去向:系统测试 → tests/system_test.gd · 施工组 G1) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 50 | 374 | "V3d 活战斗驱动完毕(战斗已收场——%d 轮,finished=%s)〔装配生命批 V3〕" ↩ % [guard, str(finished.done)] | main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live() | 活战斗自动驱动能把战斗打到收场(防驱动死循环)。 | 迁系统测试(tests/system_test.gd) | | +| 51 | 388 | "V3d2 放弃受理(选路停点天然非战斗期)〔装配生命批 V3〕" | main.run_scope.adventure.abandon() | 胜利停点后的选路停点放弃受理(天然非战斗期)。 | 迁系统测试(tests/system_test.gd) | | +| 52 | 399 | "V3e 终局面到达(adventure_finished+终局面板开)〔装配生命批 V3〕" | finished.done and main._end_overlay.is_open() | 冒险终局到达:完成信号已发+终局面板已开。 | 迁系统测试(tests/system_test.gd) | | +| 53 | 405 | "V3f 收束换会话无真空窗(新闲时会话即刻在树)〔装配生命批 V3〕" | main.run_scope != old_scope and main.run_scope.is_inside_tree() | 收束回菜单换新闲时会话且即刻在树(无真空窗)。 | 迁系统测试(tests/system_test.gd) | | +| 54 | 408 | "V3f 收束后旧局经 queue_free 消亡(is_instance_valid=false)〔装配生命批 V3〕" | not is_instance_valid(old_scope) | 旧局会话对象经队列释放真正消亡(无僵尸引用)。 | 迁系统测试(tests/system_test.gd) | | +| 55 | 421 | "V3g 营地入口缺失(选档层新档钮驱动中断)〔装配生命批 V3〕" | false | 营地入口按钮缺失时驱动显式红(防夹具静默中断)。 | 迁系统测试(tests/system_test.gd) | | +| 56 | 450 | "V3h 两连局第二会话全新对象(与第一局不复用)〔装配生命批 V3〕" | main.run_scope.adventure != null and main.run_scope.adventure != first_adv | 两连局的第二局是全新冒险对象(不复用第一局实例)。 | 迁系统测试(tests/system_test.gd) | | +| 57 | 453 | "V3h 放弃受理(第二局)〔装配生命批 V3〕" | main.run_scope.adventure.abandon() | 第二局放弃受理。 | 迁系统测试(tests/system_test.gd) | | +| 59 | 482 | "V3i 续玩换会话无真空窗(新冒险会话即刻在树)〔装配生命批 V3〕" | main.run_scope != idle_scope and main.run_scope.is_inside_tree() | 续玩换新冒险会话即刻在树(无真空窗)。 | 迁系统测试(tests/system_test.gd) | | + +## S08 _stage_faces(行 579–579 · 1 条 · 去向:系统测试 → tests/system_test.gd · 施工组 G1) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 60 | 579 | "%s(引用面违例:%s)〔装配生命批 V3〕" ↩ % [tag, bad] | bad.is_empty() | 阶段引用面零违例:恒活面对象全有效、现值回调与 run_scope 同源、存活态与附加判据全过。 | 迁系统测试(tests/system_test.gd) | | + +## S09 _smoke_run_lifecycle(行 608–640 · 11 条 · 去向:系统测试 → tests/system_test.gd · 施工组 G1) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 61 | 608 | "V3 开局建会话(is_adventure+adventure 在座)" | main.run_scope != null and main.run_scope.is_adventure and main.run_scope.adventure != null | 开局建冒险会话:冒险态真、冒险对象在座。 | 迁系统测试(tests/system_test.gd) | | +| 62 | 610 | "V3 新会话局态零值" | main.run_scope.hints_shown.is_empty() | 新会话的提示已显示集为空(局态零值起点)。 | 迁系统测试(tests/system_test.gd) | | +| 63 | 619 | "V3 放弃受理(入口停点天然非战斗期)" | main.run_scope.adventure.abandon() | 入口停点放弃受理。 | 迁系统测试(tests/system_test.gd) | | +| 64 | 624 | "V3 冒险终局到达(abandon→adventure_finished 同步链)" | finished.done | 放弃→冒险完成信号同步链到达(终局必达)。 | 迁系统测试(tests/system_test.gd) | | +| 65 | 625 | "V3 终局面板弹起" | main._end_overlay.is_open() | 终局面板弹起。 | 迁系统测试(tests/system_test.gd) | | +| 66 | 628 | "V3 会话收束(新闲时会话零冒险/关卡对象)" | not main.run_scope.is_adventure and main.run_scope.adventure == null and main.run_scope.runner == null | 会话收束后闲时会话零冒险/关卡/战斗对象。 | 迁系统测试(tests/system_test.gd) | | +| 67 | 630 | "V3 局态封印/账本全复位(queue_free 物化——注入脏状态零残留)" | main.run_scope.hints_shown.is_empty() and not main.run_scope.boss_active and main.run_scope.last_gain_amount == 0 | 注入的脏状态零残留:提示集/旗标/账本值全复位。 | 迁系统测试(tests/system_test.gd) | | +| 68 | 632 | "V3 回开始菜单(终局出口路径)" | main._start_overlay.visible | 终局出口路径回到开始菜单。 | 迁系统测试(tests/system_test.gd) | | +| 69 | 634 | "V3 放弃终局清断点(无残留断点——续玩按钮态联动)" | not sm.has_breakpoint() | 放弃终局清断点(续玩联动无残留)。 | 迁系统测试(tests/system_test.gd) | | +| 70 | 638 | "V3 第二连局全新会话(两连局零泄漏)" | main.run_scope.is_adventure and main.run_scope.adventure != null and main.run_scope.hints_shown.is_empty() | 第二连局全新会话且局态零值(两连局零泄漏)。 | 迁系统测试(tests/system_test.gd) | | +| 71 | 640 | "V3 直进入局(菜单隐)" | not main._start_overlay.visible | 直进入局后开始菜单隐藏。 | 迁系统测试(tests/system_test.gd) | | + +## S10 _smoke_node_map_templates(行 719–784 · 14 条 · 去向:集成测试 → tests/integration/node_map_templates_test.gd · 施工组 G12) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 72 | 719 | "node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)" | (svc.PURPOSE_TAGS as Array).has("node_map") | node_map 用途标签在 RNG 服务封闭词表内(扩签=改代码+此守卫)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 73 | 727 | "L%d 模板池 = 5 张(拍板点④——首版池规模)" ↩ % li | tpls.size() == 5 | 第 1–3 关每关模板池恰 5 张模板。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 74 | 728 | "L%d 模板 0 = 现行固定图(回滚档成员)" ↩ % li | not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)") | 每关模板 0 是现行固定图(显示名带现行标记)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 75 | 735 | "强制 template_pick=0 起关受理" | runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, Callable(self, "_smoke_rest_stub"), {}, true, 0) | 强制 template_pick=0 的起关调用受理。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 76 | 737 | "强制取模板零 RNG 消耗(零抽签——首次脚本语义)" | rng0.counter() == c_before | 强制取模板 0 零 RNG 消耗(首次脚本语义=零抽签)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 77 | 742 | "强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)" | runner0.level_row.get("nodes", []) == tpl0.get("nodes", []) | 强制 0 的生效节点表与模板 0 节点表逐位相等。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 78 | 753 | "抽签确定性:同种子逐位同图(实测 %s)" ↩ % String(names[0]) | names[0] == names[1] and String(names[0]) != "" | 同种子两次抽签取到同一模板(抽签确定性)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 79 | 759 | "N=1 池表通过图校验(回滚档形态合法)" | lt.validate().is_empty() | 单模板行(N=1 池)通过表图校验(回滚档形态合法)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 80 | 765 | "首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)" | String(adrF.runner().active_template_name).ends_with("(现行)") | 首次脚本模式第 1 关取模板 0(存档布尔位 false 语义)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 81 | 772 | "抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)" | rngT.counter() == cF + 1 | 抽签模式比首次脚本恰多消费 1 个 scope(逐关恰一抽签)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 82 | 773 | "抽签模式 active_template_name 有值(MC 分层出数取数面在位)" | String(adrT.runner().active_template_name) != "" | 抽签模式起关后模板名有值(测量取数面在位)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 83 | 779 | "新档首次冒险布尔位 = false(默认域)" | sv.is_first_adventure_done() == false | 新档首次冒险布尔位=false(默认域)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 84 | 782 | "冒险收束点置位(三结局同写——写入只在收束挂点)" | sv.is_first_adventure_done() == true | 冒险收束点把布尔位置 true(三结局同写、只在收束挂点)。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | +| 85 | 784 | "置位幂等(重复收束不翻转)" | sv.is_first_adventure_done() == true | 置位幂等:重复收束不翻转。 | 迁集成测试(tests/integration/node_map_templates_test.gd) | | + +## S11 _smoke_adventure_table(行 806–915 · 17 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 86 | 806 | "db.adventure 已加载(adventure.tres 入 manifest)" | _db.adventure != null | adventure 表已加载进配置宿主。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 87 | 809 | "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)" | _db.adventure.validate().is_empty() | AdventureTable 自校验零违例(全键域检+档位配额结构锚)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 88 | 810 | "clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" ↩ % int(_db.adventure.clear_heal_pct) | int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100 | 通关回血比例在 [0,100] 域内。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 89 | 813 | "档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" ↩ % [tier, int(_db.adventure.TIER_QUOTAS[tier])] | int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]) | 四档令牌配额结构=锚值(70/25/7/0)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 90 | 815 | "令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕" | int(_db.adventure.deck_total()) == 102 | 令牌池总量恰 102 张(迭代 31 卡② 净删后口径)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 91 | 816 | "数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2" | int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2 | 八类令牌件值对=正式承袭值(GP 20/50、增伤 1/2、生命 5/10、护甲 1/2)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 92 | 821 | "恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕" | int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1 | 恢复件优秀档 +6、AP 稀有档 +1(换代承袭值)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 93 | 823 | "cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕" | int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99 | GP/攻击 cap 占位 99 在域(实质无上限口径)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 94 | 825 |"同调位读数 null 档早退分支求值 0(迭代 21 T4 槽位收编——谓词表已退役)〔天赋树批 V14·核代码增补〕"| Attunement.slots(_db, null) == 0 | 同调位读数=树同调位节点点亮数(空档求值 0,谓词表已退役)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 8000 | — | "同调位读数源=树同调位节点点亮数(非空档——socket1 点亮 1 件计 1)〔天赋树批 V14·核代码增补〕" | Attunement.slots(_db, _TreeSaveMock.new()) == 1 | 同调位读数=非空档下树点亮同调位数(恒真对象引用比较已废,白盒定位补验)。 | 核代码增补(tests/unit/data_config/table_structure_guard_test.gd) | | +| 95 | 832 | "新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕" | trows.size() == 12 and tid_set.size() == 12 | 同调目录 12 件全量落表且 id 零重复。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 96 | 843 | "命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕" | names_ok | 12 件 item_id 与显示名逐件=正式定名(C2 代号即名)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 97 | 871 | "逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕" | alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2 | 逐行解锁方式与契约分配一致:8 件钥石价、4 件条件谓词互斥,档位 7/3/2。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 98 | 874 | "装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕" | orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) and orch_l.attune_has("morning_order") | 带晨操令装配起战,战斗侧 attune_has=true(装配→战斗贯通)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 99 | 878 | "空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕" | orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) and not orch_e.attune_has("morning_order") | 不带装配起战 attune_has=false(off 态零扰动基线)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 100 | 893 | "新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕" | v9_ok | 12 行效果词零棋盘改写类(rewrite_board 族零引用——棋盘事实不可变契约)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 101 | 900 | "效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕" | tag_set.has("info") and tag_set.has("action") and tag_set.has("plan") | 效果三方向覆盖标签齐备(信息/行动资源/抉择刷新)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 102 | 914 | "装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕" | v18_ok and locked_n == 12 | 白板档 12 件全未解锁且解锁模式分派正确(空目录=合法可玩态)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S12 _smoke_adventure_api(行 929–955 · 11 条 · 去向:单元测试 → tests/unit/core/adventure_feats_test.gd · 施工组 G7) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 103 | 929 | "冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)" | adr.start_adventure(42, {"first_script": true}) | 冒险启动受理(主种子注入恰一次)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 104 | 930 | "进行中重复 start_adventure 拒绝(重开须待结算完毕)" | not adr.start_adventure(43) | 进行中重复启动被拒(重开须待结算完毕)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 105 | 931 | "停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)" | sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented" | 信号次序契约:关卡开始信号先于入口候选呈现。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 106 | 933 | "入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)" | adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \\ and adr.runner().current_battle() == null | 入口停点形态:选路待选+单候选+首战未开(呈现零 RNG 消耗)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 107 | 936 | "pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)" | adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])) | 选路点选入口候选受理。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 108 | 938 | "pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)" | sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null | 点选后节点进入信号到达且首战在飞。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 109 | 940 | "战斗中 abandon 拒绝(战斗域单事务在飞)" | not adr.abandon() | 战斗中放弃被拒(战斗域单事务在飞)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 110 | 949 | "首战胜后选路态(非战斗期——current_battle 置空)" | adr.runner().current_battle() == null and adr.runner().awaiting_choice() | 首战胜后回到选路态(战斗对象置空)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 111 | 951 | "非战斗期 abandon 受理(选路态——同失败型结算)" | adr.abandon() | 非战斗期放弃受理。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 112 | 953 | "放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" ↩ % int(sum.get("keystone_total", -1)) | String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)) | 放弃结算:结果=abandoned、未打穿(已获面不追回)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 113 | 955 | "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)" | not adr.abandon() | 结算后重复放弃被拒(结算只受理一次)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | + +## S13 _smoke_adventure(行 980–1287 · 54 条 · 去向:单元测试 → tests/unit/core/adventure_feats_test.gd · 施工组 G7) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 114 | 980 | "序列关数 = 行集关数(三关正式序列生效)" | int(_db.level.rows.size()) == 3 | 关卡行集 3 行(三关正式序列生效)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 115 | 996 | "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" ↩ % miss | miss == 0 | 行集内每个战斗/Boss 节点在棋盘生成表与敌人表都有精确行集(覆盖义务)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 116 | 1000 | "满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)" | String(st["result"]) == "completed" and bool(sum.get("cleared", false)) | 满配通关(调试攻血覆盖)达成 cleared=true。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 117 | 1002 | "通关几关 = 3(levels_cleared)" | int(sum.get("levels_cleared", -1)) == 3 | 通关数=3。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 118 | 1006 | "钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" ↩ % int(sum.get("keystone_total", -1)) | int(sum.get("keystone_total", -1)) == ks_exp | 钥石合计=各关 Boss 首杀行值之和(逐杀掉落口径)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 119 | 1009 | "逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)" | drops.size() == 3 | 逐杀掉落明细恰 3 笔(每关一笔)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 120 | 1010 | "摘要携带主种子(42)" | int(sum.get("master_seed", -1)) == 42 | 冒险摘要携带主种子 42。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 121 | 1021 | "通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)" | picks.size() == 2 and resolves.size() == 2 + node_picks | 通关三选一恰呈现 2 次、恰兑付 2 次(末关不发)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 122 | 1023 | "奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" ↩ % node_picks | node_picks >= 1 | 奖励节点三选一至少经停 1 次。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 123 | 1028 | "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" ↩ % [node_resolves, node_picks] | node_resolves == node_picks | 奖励节点兑付回执与经停数逐笔相等(同账计数)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 124 | 1078 | "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)" | cand_ok | 三选一候选 2–3 枚且零重复(令牌洗牌不放回)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 125 | 1079 |"通关部分回血封顶(floor(观测上限×比例) 封顶——购血观测值精确断言,核代码增补)"| heal_ok | 通关回血按生效上限比例封顶(血回满语义退役,含购血容差窗口)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 126 | 1080 | "钥石逐杀入账(首杀档位 + 累计逐位)" | ks_gain_ok | 钥石逐杀入账:首杀档位+累计前缀逐位一致。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 127 | 1081 | "点选恰兑付选中一件(值=令牌档位表值;未选项零生效)" | resolve_ok | 点选恰兑付选中一件且值=表值(未选项零生效)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 128 | 1092 | "各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" ↩ % exp_base | seq_ok | 各战敌人上限入参落在[基础, 基础+件值+购血余量]窗口(覆盖值通道)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 129 | 1096 | "同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)" | st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"] | 同种子两局全量逐位可复现(摘要/上限序列/行进/三选一载荷)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 130 | 1106 | "零配 L1 败北种子可达(seed=%s)" ↩ % str(lose.get("s", -1)) | not lose.is_empty() | 零配 L1 败北种子可达(败北线存在性)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 131 | 1109 | "L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层" | not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \\ and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1 | L1 败北摘要:未打穿、0 关、0 钥石、止步第 1 层。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 132 | 1113 | "L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)" | int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \\ and String(dl.get("node_type", "")) in ["battle", "boss"] | L1 败北明细:层号/节点编号/节点类型在案(数值验证取数面)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 133 | 1122 | "半配(atk+50)L2 败北种子可达(seed=%s)" ↩ % str(half.get("s", -1)) | not half.is_empty() | 半配 L2 败北种子可达。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 134 | 1125 | "L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层" | not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \\ and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \\ and int(sh["stopped_at"]) == 2 | L2 败北摘要:已过 L1(钥石=L1 首杀)且止步第 2 层。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 135 | 1130 | "L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)" | int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \\ and String(dh.get("node_type", "")) in ["battle", "boss"] | L2 败北明细:层号=2、节点在案。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 136 | 1136 | "重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)" | adr6.start_adventure(7, {"first_script": true}) | 重复拒绝场景就位:固定模板启动冒险。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 137 | 1144 | "重复拒绝场景就位:选路态放弃 → 已结算" | adr6.abandon() | 选路态放弃→已结算(场景推进到可重复宣布态)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 138 | 1147 | "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)" | adr6.summary == sum6_before | 重复关卡结束宣布被拒:摘要零变化(契约 7)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 139 | 1157 | "每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" ↩ % [gains, int(st["battles"])] | gains == int(st["battles"]) | 每战完成 GP 入账恰一次(三选一 GP 袋件入账单列不算)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 140 | 1158 | "钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)" | clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear" | 钱包 clear 事件恰一次且为末事件(关卡收束不清账)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 141 | 1161 | "L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" ↩ % (shop_seq[0] if shop_seq.size() > 0 else -1) | shop_seq.size() == 2 and int(shop_seq[0]) > 0 | L2 商店房到达时余额>0(L1 积累跨关延续)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 142 | 1173 | "同账场景种子可达(seed=%s——L2 商店房就位)" ↩ % str(same.get("s", -1)) | not same.is_empty() | 同账场景种子可达(L2 商店房就位)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 143 | 1186 | "进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" ↩ % [hp_price + l2_price_bonus, hp_price, l2_price_bonus] | gp0 >= hp_price + l2_price_bonus | 进店余额≥hp_up 该关首价(基价+关卡加成,配置推导)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 144 | 1188 | "购买 hp_up 受理(出账 + 加算区)" | r8.buy_product("hp_up") | 购买 hp_up 受理(出账+进加算区)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 145 | 1190 | "统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" ↩ % acc_shop | acc_shop >= 3 | 统一冒险加算区含商店购得值(同账)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 146 | 1193 | "L2 通关三选一停点在案(直调兑付前——零自动推进)" | not a8._curio_pending.is_empty() | L2 通关后三选一停点在案(零自动推进)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 147 | 1194 | "未兑付推进拒绝(选择停点零自动推进契约)" | not a8.continue_to_next_level() | 未兑付时推进被拒(选择停点零自动推进契约)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 148 | 1197 | "三选一点选兑付受理(候选取首件)" | a8.pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", "")), String(l2_pick.get("feat_id", ""))) | 三选一点选兑付受理。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 149 | 1200 | "三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" ↩ % [int(a8.run_ledger.bonus.get("max_hp", -1)), acc_shop, picked2] | int(a8.run_ledger.bonus.get("max_hp", -1)) == expect_acc | 三选一属性件与商店购买同账累加(同一本账)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 150 | 1203 | "通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" ↩ % [int(a8.ledger["hero_hp"]), max_eff8] | int(a8.ledger["hero_hp"]) <= max_eff8 | 通关回血后血量不越生效上限(封顶口径)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 151 | 1205 | "兑付后推进受理(下一关开始)" | a8.continue_to_next_level() | 兑付后推进受理(下一关开始)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 152 | 1208 | "恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法" | int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \\ and int(a9.apply_rest(30, 100, 100)) == 0 | 恢复点数学三读数:30% 回复/封顶缺口/满血回 0。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 153 | 1212 | "同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)" | int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5 | 同一恢复公式在两个落点逐位一致。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 154 | 1217 | "放弃场景:冒险启动(seed 15——固定模板 0 同上)" | a10.start_adventure(15, {"first_script": true}) | 放弃场景冒险启动受理。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 155 | 1218 | "放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)" | a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])) | 入口停点点选进首战。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 156 | 1220 | "战斗中 abandon 拒绝(战斗域单事务在飞)" | not a10.abandon() | 战斗中放弃被拒。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 157 | 1227 | "非战斗期(选路态)abandon 受理" | a10.runner().current_battle() == null and a10.abandon() | 非战斗期放弃受理(选路态)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 158 | 1231 | "剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)" | a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}) | 剔牌库场景启动(道具解锁集只含阔视野带)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 159 | 1241 | "池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" ↩ % str(draw) | not draw_kinds.has("attack") and not draw_kinds.has("ap") | attack/ap 令牌耗尽后候选零出现(池耗即枯竭)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 160 | 1243 | "池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)" | (draw as Array).size() >= 2 | 池耗后候选仍≥2(防三选项全不可选死角)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 161 | 1252 | "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)" | not treasure_seen | 40 次重抽零 aux 候选(停点渠道 aux 卡退役)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 162 | 1261 | "满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)" | int(cap["payload"].get("heal_gained", -1)) == 0 | 满血通关回血 0 合法(封顶口径非负溢出)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 163 | 1265 | "节点共享池场景:冒险启动(seed 13)" | a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}) | 节点共享池场景启动。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 164 | 1277 | "奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" ↩ % str(ncands13) | not has_attack13 | 令牌耗尽后奖励节点同样零出候选(同库同池)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 165 | 1282 | "奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" ↩ % int(_db.adventure.curio_value("gp", "common")) | a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common")) | 奖励节点 GP 件入账走钱包收口。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 166 | 1284 | "节点停点态兑付后清零(恰一次语义同构)" | a13._node_curio_pending.is_empty() | 节点停点态兑付后清零(恰一次语义)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 167 | 1286 | "放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)" | String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)) | 放弃摘要:abandoned、未打穿(已获不追回)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | + +## S14 _smoke_feats(行 1295–1637 · 71 条 · 去向:单元测试 → tests/unit/core/adventure_feats_test.gd · 施工组 G7;去向补正:转办行→cross_system_flow_test.gd,见维护记录) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 168 | 1295 | "feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)" | _db.feats != null and (_db.feats.rows as Array).size() == 30 | 专长表已加载且恰 30 行(基础 27+迁入 2+完美保险 1)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 169 | 1296 | "feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)" | _db.feats.validate().is_empty() | 专长表自校验零违例(四级稀有度/上限域/触发词表/行键白名单)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 170 | 1298 | "专长带场景:冒险启动(seed 71)" | a_r0.start_adventure(71, {"first_script": true}) | 专长带场景冒险启动受理。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 171 | 1306 | "专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕" | feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0._curio_deck.size() == 132 | 30 件专长以 relic 令牌入池且档位直映射(12/9/9/0),牌库 102+30=132。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 172 | 1309 | "旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕" | _db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty() | 旧 27 件代表行(arcane_ember/magma_body)表内零残留(旧代已退役)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 173 | 1311 | "迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕" | _db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty() | 迁出收尾:秘宝表无 venom_edge/seal_burst 行(同调目录收窄)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 174 | 1319 | "层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕" | kinds_ok | 层级纪律:牌库 kind 全集=表内 CURIO_KINDS+relic(秘宝 id 零入池)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 175 | 1325 | "棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕" | rewrite_rows == 0 | 30 行触发器零棋盘改写类订阅(rewrite_board 已除名消费面)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 176 | 1332 | "对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕" | bad_words == 0 | 30 行显示名与描述零「雷/旗」字面(对外命名纪律初筛)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 177 | 1333 | "数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕" | float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3 | 数值纪律锚:陷阱爆发倍率>1、血蜜回复<典型陷阱伤、石肤上限 3。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 178 | 1351 | "新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)" | rt.validate().is_empty() | 新 schema 手搓 5 行临时表自校验零违例(流派标签已退役零 archetype)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 179 | 1355 | "专长带场景:冒险启动(seed 72——注入 5 行临时表)" | a_r.start_adventure(72, {"first_script": true}) | 注入临时表后冒险启动受理。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 180 | 1360 | "专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=107" | deck_relic == 5 and a_r._curio_deck.size() == 107 | 5 行 relic 每件 1 张入池,牌库 102+5=107。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 181 | 1372 | "通关渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)" | feat_seen.size() >= 2 | 通关渠道 60 次重抽出≥2 种专长(只出专长)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 182 | 1373 | "节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)" | not node_relic_seen | 节点渠道 60 次重抽零专长(专长在非 Boss 渠道只以凭证形态出现)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 183 | 1381 | "A5 专长凭证兑付受理(节点渠道——拿到即开)" | a_t.apply_node_curio("feat_ticket", "excellent") | 专长凭证节点兑付受理(拿到即开)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 184 | 1387 | "A5 拿到即开——停点候选已替换为专长三选一(全 relic)" | tkt_all_relic | 凭证兑付后停点候选已替换为全 relic 专长三选一。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 185 | 1388 | "A5 零摘牌——专长凭证兑付不动令牌库(不进持有集)" | a_t.pool_stock("attack") == int(stock_before["attack"]) and a_t.pool_stock("gp") == int(stock_before["gp"]) | 凭证兑付零摘牌:令牌库库存不动(凭证不进持有集)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 186 | 1390 | "A5 无专长凭证残留持有集" | not (a_t.feats_held() as Array).has("feat_ticket") | 凭证本体不在持有集残留。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 187 | 1393 | "A5 专长面兑付受理(专长凭证开出候选可正常入持有集)" | (a_t.feats_held() as Array).size() == feats_n + 1 | 凭证开出候选可正常兑付入持有集(计数+1)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 188 | 1398 | "A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)" | a_t._feat_ticket_consider(rg_t) | 保底 pity 满步时下 consider 必触发(概率无关)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 189 | 1399 | "A6 触发后清零" | a_t._feat_ticket_pity == 0 | 保底触发后 pity 清零。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 190 | 1402 | "A6 限购满不再触发(保底冻结)" | not a_t._feat_ticket_consider(rg_t) | 限购满后保底冻结不再触发。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 191 | 1410 | "A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)" | offered | 概率面存在:20% 概率 200 次展示内必出现(零触发=概率面失守)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 192 | 1412 | "relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" ↩ % [deck_relic, deck_relic - 1] | a_r.pick_curio("relic", "rare", "x3") and (a_r.feats_held() as Array).has("x3") and a_r.pool_stock("relic") == deck_relic - 1 | relic 兑付:入持有集+牌库摘牌(选中踢池)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 193 | 1420 | "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)" | (bp_r.get("relics", []) as Array).has("x3") | 断点快照附随 relics 持有集(续玩还原面)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 194 | 1421 | "断点附随件:adventure 计数器账本键在案(T6/T7 扩列)" | bp_r.has("adventure_counters") | 断点快照附随 adventure 计数器账本键。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 195 | 1426 | "断点余库:已兑付 relic 令牌逐枚还原(x3 零残留——续玩不重复获得)" | bp_deck_x3 == 0 | 断点余库逐枚还原:已兑付 relic 令牌零残留(续玩不重复获得)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 196 | 1449 | "双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" ↩ % (i + 1) | refund | 双管镐:每第 2 次付费挖格返 1 AP(mod_eq 预评估口径)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 197 | 1451 | "双管镐反例:第 %d 次不返(未满 2 次)" ↩ % (i + 1) | not refund | 双管镐反例:未满 2 次不返 AP。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 198 | 1455 | "地质学家:首开新类型集 1 点〔专长批 V3〕" | orch._hits_of(geo_hits, "counter_add").size() == 1 | 地质学家:首开新资源类型积 1 点。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 199 | 1459 | "地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕" | orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.counters_ctx, "geo_types") == 0 | 地质学家满 4 种:揭示 1 格且计数清零。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 200 | 1462 | "地质学家反例:同类型重复首开不叠点〔专长批 V3〕" | orch._hits_of(geo_hits3, "counter_add").is_empty() | 地质学家反例:同类型重复首开不叠点。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 201 | 1466 | "安全帽:开安全格攒层 ×2〔专长批 V3〕" | EffectEngine.counter(orch.counters_ctx, "helm_layers") == 2 | 安全帽:开安全格攒层 ×2。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 202 | 1472 | "安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕" | hh_reduce == 2 | 安全帽:踩雷耗层减伤=层值 2(一次清零)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 203 | 1476 | "跳格采掘:空白 0 格攒 1 点〔专长批 V3〕" | orch._hits_of(sk_zero, "counter_add").size() == 1 | 跳格采掘:空白 0 格攒 1 点。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 204 | 1480 | "跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕" | orch._hits_of(sk_full, "reveal_tiles").size() == 1 | 跳格采掘满 3 点:揭示 1 格。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 205 | 1482 | "跳格采掘反例:普通开格不计点〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty() | 跳格采掘反例:普通开格不计点。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 206 | 1490 | "爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1 | 爆破会计:本轮挖格花费≥5 轮末+1 GP。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 207 | 1492 | "边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1 and orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty() | 边陲矿工:边缘格产出材料、中心格反例零产出。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 208 | 1500 | "封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕" | orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue" | 封印回响:标中计数≥2 轮末返 1 AP(queue 模式)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 209 | 1510 | "封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕" | sm_remove == 1 | 封印匠:标中每第 3 次中立移除恰一次(频率闸死)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 210 | 1512 | "封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1 and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round" | 封印桩:标中本轮 AC 加值(round 域,轮始清零)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 211 | 1516 | "示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "preview_area").size() == 1 | 示警烟火:标中揭示 3×3 资源类型(不揭安危位置)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 212 | 1525 | "疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕" | pm_bonus == 2 | 疼痛记忆:攻击附加层值伤害且用后清零。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 213 | 1532 | "保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty() | 保险丝反例:单次受伤<5 不攒点不触发。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 214 | 1537 | "保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕" | fw_shield == 0 | 保险丝首点不出盾(2 点才出,预评估口径)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 215 | 1539 | "见血开窍:踩雷揭示 1 格〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1 | 见血开窍:踩雷揭示 1 格。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 216 | 1546 | "陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕" | ti_val == 2 | 陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 217 | 1551 | "连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕" | EffectEngine.counter(orch.counters_ctx, "pain_layers") == ct_before + 2 | 连环陷阱:同轮第 2 颗起每颗额外 +1 层(与疼痛记忆同账)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 218 | 1554 | "陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕" | orch._hits_of(tb_hits, "blast_scaled").size() == 1 and float((orch._hits_of(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0 | 陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled 通道)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 219 | 1558 | "血蜜:踩雷回复 N 生命〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "heal").size() == 1 | 血蜜:踩雷回复 N 生命。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 220 | 1561 | "石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕" | orch._hits_of(ss_hits, "ac_bonus").size() == 1 and int((orch._hits_of(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3 | 石肤:受伤 AC+1(本场域 cap 3,封顶归累积器)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 221 | 1565 | "背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1 and orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty() | 背水:生命≤50% 减伤触发、过半血反例零减伤。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 222 | 1570 | "破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 and orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty() | 破封一击:挖开带封印危险格 +1 层、无封印反例零触发。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 223 | 1578 | "弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕" | orch._hits_of(me_hits, "counter_add").size() == 1 | 弃图兑换:停手剩余 AP≥3 入账。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 224 | 1582 | "弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕" | me_atk == 4 | 弃图兑换:折为攻击伤害=剩余 AP 全额且用后清零。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 225 | 1583 | "弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty() | 弃图兑换反例:剩余 AP<3 不触发。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 226 | 1587 | "一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕" | orch._hits_of(ld_hits, "grant_ap").size() == 1 | 一滴不剩:上轮 AP 耗尽→本轮 +1 AP。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 227 | 1588 | "一滴不剩反例:上轮有余不发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty() | 一滴不剩反例:上轮有余不发。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 228 | 1595 | "收盘:本轮开格≥8→+1 GP〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1 | 收盘:本轮开格≥8 轮末+1 GP。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 229 | 1598 | "斩首学者:击杀揭示 1 格〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1 | 斩首学者:击杀揭示 1 格。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 230 | 1602 | "处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕" | orch._hits_of(ex_hits, "target_attack_bonus").size() == 1 and orch._hits_of(fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty() | 处刑台:敌血量≤30% 挂标记加伤、阈值外反例零命中。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 231 | 1606 | "战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1 | 战地包扎:战斗胜利回复 N 生命(battle_won 挂点)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 232 | 1610 | "血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "bonus_delta").size() == 1 and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk" | 血税:双向同局账本(踩陷阱减 max_hp/封印命中加 atk,战末汇入)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 233 | 1615 | "淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1 and orch._hits_of(fx.dispatch(orch.counters_ctx, "clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty() | 淬毒刃纹(迁入):克制正面追加效果、中性面反例零触发。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 234 | 1619 | "封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "open_ticket").size() == 1 | 封印爆裂(迁入):标中全体敌群伤。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 235 | 1624 | "零持有订阅=零命中(relic 关着零扰动——伤害管线既有行为逐位不变)" | orch0._fx_emit("attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}).is_empty() and orch0._fx_emit("dig_reveal", {"cell": Vector2i(1, 1), "round": 1, "is_first_dig_of_battle": true, "terrain_kind": []}).is_empty() and orch0._fx_emit("mine_hit", {"damage": 5}).is_empty() and orch0._fx_emit("battle_won", {"round": 1}).is_empty() | 零持有订阅零命中(relic 关着零扰动,伤害管线既有行为不变)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 236 | 1632 | "特性段空态隐藏(视图空态纪律同特性段)" | not panel._feat_section.visible | 特性段空态隐藏(视图空态纪律)。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 237 | 1634 | "特性段持有呈现(逐件行常驻可读——「局内奖励选了什么看不到」缺口承载)" | panel._feat_section.visible and panel._feat_list.get_child_count() == 2 | 特性段持有呈现:逐件行常驻可读。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | +| 238 | 1636 | "特性卡文案表值化:desc 行字段直读(零硬编码映射)" | String(load("res://presentation/battle_narrator.gd").feat_card_text({"feat_id": "pain_memory", "display_name": "疼痛记忆", "desc": "触陷阱攒 1 层"})).contains("触陷阱攒 1 层") | 特性卡文案表值化:desc 字段直读零硬编码映射。 | 迁单元测试(tests/unit/core/adventure_feats_test.gd) | | + +## S15 _smoke_layout_cell_formula(行 1703–1704 · 2 条 · 去向:单元测试 → tests/unit/core/board_gen_reward_test.gd · 施工组 G3) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 239 | 1703 | "L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" ↩ % [L, b.width, b.height, grid_h, int(avail.y), int(cell)] | grid_h <= int(avail.y) - 24 | 网格实高(含格间距)≤ 可用高−24 预留(下缘不贴边不裁切——真人试玩 bug⑥ 回归)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 240 | 1704 | "L%d 格边在 26–46 档内(%dpx)" ↩ % [L, int(cell)] | cell >= 26.0 and cell <= 46.0 | 格边长在 26–46px 档内。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | + +## S16 _smoke_type_glow(行 1799–1953 · 24 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 241 | 1799 | "V1 微光默认开(Q4 丙案——呈现态布尔缺省 true,生产写入面零显式开)" | bool(snap.get("enabled", false)) | 类型微光默认开启(呈现态布尔缺省 true)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 242 | 1800 | "V1 夹具全集合法九型 ⇒ 清单外登记为空(禁静默空光通道零误报)" | (snap.get("errors", []) as Array).is_empty() | 夹具全集合法九型时清单外登记为空(禁静默空光零误报)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 243 | 1802 | "V1 作用集恒等:微光格集合 == _diggable ∩ 身份可查(期望 %d,实测 %d)" ↩ % [expect.size(), (snap.get("cells", {}) as Dictionary).size()] | _glow_key_set_equal(snap.get("cells", {}), expect) | 微光格集合与独立重算期望集恒等(可挖∩身份可查)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 244 | 1804 | "V1 夹具前提:英雄未开邻格 ≥5(实测 %d——novice %d 步)" ↩ % [expect.size(), steps] | expect.size() >= 5 | 夹具前提:英雄未开邻格≥5(驱动步数在册)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 245 | 1813 | "V1 已开格/r0 出生展开区零微光(S1)%s" ↩ % ("" if opened_leak == "" else "违例:" + opened_leak) | opened_leak == "" | 已开格与出生展开区零微光。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 246 | 1829 | "V2 夹具前提:未开邻格派定 ≥5 型(实测 %d)" ↩ % mapped | mapped >= 5 | 夹具前提:未开邻格派定≥5 型。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 247 | 1838 | "V2 九型→五组逐格映射(attack/defense/action/gp→增益/heal→治疗/pierce→穿刺/blast→群伤/magic→魔法)%s" ↩ % ("" if map_bad == "" else "违例:" + map_bad) | map_bad == "" | 九型→五组逐格映射正确(增益/治疗/穿刺/群伤/魔法)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 248 | 1851 | "V2 清单外 cell_type 显式登记(errors=%s)" ↩ % str(errs) | errs.has("keystone") | 清单外 cell_type 显式登记进 errors(不静默)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 249 | 1852 | "V2 清单外格零微光(无静默空光——%s 不在作用集)" ↩ % str(bogus_cell) | not (snap.get("cells", {}) as Dictionary).has(bogus_cell) | 清单外格零微光。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 250 | 1854 | "V2 清单外格剔除后作用集 = 派定数 −1(%d→%d)" ↩ % [mapped, (snap.get("cells", {}) as Dictionary).size()] | (snap.get("cells", {}) as Dictionary).size() == mapped - 1 | 清单外格剔除后作用集=派定数−1。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 251 | 1867 | "V1 封锁格零微光(query null ⇒ 天然惰性——%s)" ↩ % str(blocked_cell) | not (snap.get("cells", {}) as Dictionary).has(blocked_cell) | 封锁格(查询 null)零微光(天然惰性)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 252 | 1869 | "V1 封锁格零登记(errors 仍仅清单外一条——%s)" ↩ % str(snap.get("errors", [])) | (snap.get("errors", []) as Array).size() == 1 | 封锁格零登记(errors 仍仅清单外一条)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 253 | 1871 | "V1 封锁格剔除后作用集 = 派定数 −1(实测 %d)" ↩ % (snap.get("cells", {}) as Dictionary).size() | (snap.get("cells", {}) as Dictionary).size() == mapped - 1 | 封锁格剔除后作用集=派定数−1。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 254 | 1877 | "V1 封锁解除后作用集复位恒等(期望 %d,实测 %d)" ↩ % [expect.size(), (snap.get("cells", {}) as Dictionary).size()] | _glow_key_set_equal(snap.get("cells", {}), expect) | 封锁解除后作用集复位恒等(进出圈复亮走回)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 255 | 1883 | "V7 off 态开关读数=false" | not bool(snap.get("enabled", true)) | off 态开关读数=false。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 256 | 1884 | "V7 off 态作用集为空(基线保护面)" | (snap.get("cells", {}) as Dictionary).is_empty() | off 态作用集为空(基线保护面)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 257 | 1887 | "V7 on 态开关读数=true(复现)" | bool(snap_on.get("enabled", false)) | on 态开关读数=true(复现)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 258 | 1888 | "V7 off→on 作用集逐位复现(关前 %d 格/开后 %d 格,组号逐位一致)" ↩ % [ (snap_before_off.get("cells", {}) as Dictionary).size(), (snap_on.get("cells", {}) as Dictionary).size()] | _glow_map_equal(snap_on.get("cells", {}), snap_before_off.get("cells", {})) | off→on 作用集逐位复现(组号一致)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 259 | 1891 | "V7 off→on 作用集与独立重算期望集恒等(%d 格)" ↩ % (snap_on.get("cells", {}) as Dictionary).size() | _glow_key_set_equal(snap_on.get("cells", {}), expect) | off→on 作用集与独立重算期望集恒等。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 260 | 1895 | "V7 微光开关零玩法写入(已开集/英雄位/封印数三面对照逐位一致)" | revealed_before == revealed_after and hero_before == ms.hero_pos() and seals_before == ms.seal_count() | 微光开关零玩法写入(已开集/英雄位/封印数三面零扰动)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 261 | 1908 | "V3 换图 rebuild 后作用集按新板重算(期望 %d,实测 %d——零旧板残留)" ↩ % [expect2.size(), (snap2.get("cells", {}) as Dictionary).size()] | _glow_key_set_equal(snap2.get("cells", {}), expect2) | 换图 rebuild 后作用集按新板重算(零旧板残留)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 262 | 1910 | "V3 换图清清单外登记(rebuild 清零)" | (snap2.get("errors", []) as Array).is_empty() | 换图清清单外登记(rebuild 清零)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 263 | 1951 | "契约 1 同板同序列 on/off 两态结算逐位一致(%d 步对照——A 开态/B 关态)%s" ↩ % [seq_steps, diverge] | diverge == "" | 同板同序列 on/off 双实例结算逐位一致(微光派生零写板态)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 264 | 1953 | "契约 1 对照前提:同序列 ≥5 步(实测 %d)" ↩ % seq_steps | seq_steps >= 5 | 对照前提:同序列≥5 步(非空转)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S17 _smoke_reward_identity_unit(行 1986–2019 · 10 条 · 去向:单元测试 → tests/unit/core/board_gen_reward_test.gd · 施工组 G3) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 265 | 1986 | "roll_board 受理" | reward_economy.roll_board(eligible) | 资源格摇号 roll_board 受理。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 266 | 1987 | "重复 roll_board 被拒(恰一次——double-roll 守卫)" | not reward_economy.roll_board(eligible) | 重复 roll_board 被拒(恰一次守卫)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 267 | 1998 | "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" ↩ % str(types_seen.keys()) | types_ok | 身份查询投影={reward_id,cell_type,tier} 且五型封闭。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 268 | 2000 | "清单外坐标查询 = null(封锁格无身份同口径)" | reward_economy.query_identity(Vector2i(99, 99)) == null | 清单外坐标身份查询=null(封锁格无身份)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 269 | 2004 | "同格重复 grant 幂等(同一快照——受理恰一次)" | JSON.stringify(snap1) == JSON.stringify(snap2) | 同格重复 grant 幂等(同一快照)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 270 | 2005 | "清单外 grant 拒绝" | reward_economy.grant(Vector2i(99, 99)) == null | 清单外坐标 grant 拒绝。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 271 | 2009 | "清图批尾身份 = clear_blast(clear_default 唯一活行)" | String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast" | 清图批尾身份=clear_blast(clear_default 唯一活行)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 272 | 2014 | "清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" ↩ % [cb_range[0], cb_range[1], int(cs1.power)] | int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1]) | 清图奖励 power 落在表区间(配置推导)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 273 | 2016 | "grant_clear 同批次重调幂等" | JSON.stringify(cs1) == JSON.stringify(cs2) | grant_clear 同批次重调幂等。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 274 | 2019 | "映射退役后 grant 拒绝(生命周期闭合)" | reward_economy.grant(eligible[0].cell) == null | 映射退役后 grant 拒绝(生命周期闭合)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | + +## S18 _smoke_seal_ap_exhausted(行 2034–2058 · 5 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 275 | 2034 | "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)" | settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | 封印回归战斗启动(Boss 行承载)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 276 | 2047 | "%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" ↩ % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap] | settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1 | 表驱动次数挖掘后恰剩 1 AP。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 277 | 2051 | "仍存在可挖目标(封印目标来源)" | not plan2.is_empty() | 仍存在可挖目标(封印目标来源)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 278 | 2057 | "末点 AP 封印被受理" | not out.is_empty() and bool(out[0].accepted) | 末点 AP 封印被受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 279 | 2058 | "封印动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" ↩ % str(rounds) | rounds.size() >= 2 | 末点封印触发轮末结算进入第 2 轮(playtest 缺陷②回归)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S19 _smoke_level(行 2069–2094 · 11 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 280 | 2069 | "关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" ↩ % [win.result, win.defeat_node_index] | win.result == "victory" | 关卡满配通关达成。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 281 | 2070 | "战斗节点数 = 5(战×4 + Boss)" | win.battles == 5 | 战斗节点数=5(战×4+Boss)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 282 | 2071 | "Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)" | win.boss_depth == 4 | Boss 节点深度=4(第三轴登记约定)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 283 | 2072 | "进 Boss HP 携带在域内(%d)" ↩ % win.hp_at_boss | win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3 | 进 Boss 时血量携带在域内。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 284 | 2079 | "恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" ↩ % [rest.rest_healed, expect, eff_max] | rest.rest_healed == expect | 恢复点回复=floor(生效上限×30%) 与缺口的较小值。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 285 | 2081 | "恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" ↩ % rest.result | false | 恢复点未到达时显式红(防夹具空转)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 286 | 2084 | "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" ↩ % lose.result | lose.result == "defeat" | 裸装败北路径可达(Roguelike 基线难度)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 287 | 2085 | "败北节点记录在域内(节点 %d)" ↩ % lose.defeat_node_index | lose.defeat_node_index >= 0 and lose.defeat_node_index < 7 | 败北节点记录在域内。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 288 | 2089 | "关卡回放断言:同种子两遍结算步骤流逐行相同" | JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows) | 同种子两遍结算步骤流逐行相同(关卡回放确定性)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 289 | 2090 | "关卡步骤流非空(%d 行)" ↩ % rep1.rows.size() | rep1.rows.size() > 0 | 关卡步骤流非空。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 290 | 2092 | "模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" ↩ % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)] | int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \\ and int(_db.hero.armor_class) == int(hero_base.ac) | 模拟后主角表恢复基线(覆盖不泄漏到后续会话)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S20 _smoke_enemy_bands(行 2115–2143 · 4 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 291 | 2115 | "敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕" | _db.enemy.rows.size() == 20 and got_keys == expect_keys | 敌人表 20 行且键序=迁移表序(行序即契约)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 292 | 2122 | "hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕" | int(hob.get("level_index", -1)) == 1 and String(hob.get("row_kind", "")) == "battle" \\ and int(hob.get("depth_band", -1)) == 3 | hobgoblin 缺口行在 (1,battle,3) 留任随迁(不删不补)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 293 | 2125 | "hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕" | FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行") | 缺口登记随行:表文件注释在位(缺口不静默)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 294 | 2142 | "三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" ↩ % [ _boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail] | boss_ok | 三关 Boss 行各恰一行且深度带=图 Boss 节点深度。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S21 _smoke_enemy_attack_type(行 2161–2205 · 4 条 · 去向:单元测试 → tests/unit/battle_settlement/roll_multiplier_test.gd · 施工组 G4) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 295 | 2161 | "敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" ↩ % [ _db.enemy.rows.size(), at_detail if at_detail != "" else "无"] | at_ok | 敌方全部行 attack_type 全填 normal(轴开列)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 296 | 2201 | "敌方单位快照 attack_type 非空(spawn 注入面——迭代 24 N3)" | snap_ok | 敌方单位快照 attack_type 非空(spawn 注入面)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 297 | 2202 | "克制矩阵命中证词:同种子三甲型实测 heavy %d == light %d − 1 · ward %d == light %d + 1(迭代 24 N3 全局平移 −1/0/+1)" ↩ % [ t_heavy, t_light, t_ward, t_light] | matrix_found and t_light >= 2 | 克制矩阵命中证词:同种子三甲型实测 heavy=light−1、ward=light+1。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 298 | 2205 | "敌方伤害技单据载荷 attack_type 非空(= %s——第 9 参传值面,迭代 24 N3)" ↩ % payload_at | payload_at == "normal" | 敌方伤害单据载荷 attack_type 非空。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | + +## S22 _smoke_ac_swap_guard(行 2218–2320 · 12 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 299 | 2218 | "状态表 defense_up 行已退役(无双轨)" | _db.status_effect_row("defense_up").is_empty() | 状态表 defense_up 行已退役(无双轨)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 300 | 2220 | "ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)" | not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \\ and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \\ and String(ac_row.get("stack_policy", "")) == "refresh" | ac_up 行在位:主角侧+1AC 只续时不叠层。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 301 | 2228 | "奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)" | String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护" | 奖励表 defense 正向行 effect_id=ac_up(盾牌加护)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 302 | 2230 | "effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)" | not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up") | 效果池映射无 defense_up/ac_up 键(AC 不经乘区)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 303 | 2245 | "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" ↩ % [ wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)] | wall_ok | 免伤墙归零护栏:percent 行叠满不可触池底(BUG-0003 免疫类防回归)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 8001 | — | "hero_defense 池映射数==0(绊线钉——红=已加映射,去核上方护栏是否已同步激活)" | hd_maps == 0 | 钉住 hero_defense 池空集现态(核代码增补)——有人加映射先红,提醒护栏转激活需核验。 | 核代码增补(tests/unit/data_config/table_structure_guard_test.gd) | | +| 304 | 2248 | "主角 armor_class 在域内(%d——暂定基准随表走)" ↩ % int(_db.hero.armor_class) | int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30 | 主角 AC 在 [0,30] 域内(暂定基准随表走)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 305 | 2265 | "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" ↩ % [ ac_detail if ac_detail != "" else "无"] | ac_band | 敌方 AC 行值在裁带宽内(杂鱼 0~5/重击手 8~9/Boss 8~12)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 306 | 2273 | "Boss AC 关卡台阶严格递增(%d < %d < %d)" ↩ % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))] | int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)) | Boss AC 关卡台阶严格递增(L1= 0 and not l12_row.is_empty() and int(l12_row.get("hp", -1)) == 22 \\ and int(l12_row.get("armor_class", -1)) == 2 \\ and l12_pool == {"attack": 6, "charge": 2, "heavy": 2} | 熊地精首遇带降档锚定:hp22/ac2/池 attack6-charge2-heavy2 零改动。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 308 | 2311 | "第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" ↩ % [ curve_detail if curve_detail != "" else "无"] | curve_ok | 第一关相邻深度带总量比≤2.5(难度曲线护栏)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 309 | 2316 | "掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)" | int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \\ and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \\ and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll" | 掷点参数拍板值在位(d20/nat20×2/下限 1/加成 0/专流)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 310 | 2320 | "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)" | bounds == [3, 7] and discounts == [75, 50, 25] | 折扣档表拍板值在位(3 档边界与折扣无零档)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S23 _smoke_armor_roll(行 2339–2400 · 15 条 · 去向:单元测试 → tests/unit/battle_settlement/roll_multiplier_test.gd · 施工组 G4) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 311 | 2339 | "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" ↩ % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])] | tier_ok and dist == cfg.expect | 查档纯函数全掷点域分布逐位正确(AC10/AC12)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 312 | 2343 | "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" ↩ % ev_sum | ev_sum == 1575 | AC10 期望系数锚=1575/2000(期望减免 21.25%)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 313 | 2353 | "nat20 种子可达(seed=%d——主角先手首掷命中 %d)" ↩ % [crit_seed, int(_db.settlement.crit_roll)] | crit_seed > 0 | nat20 种子可达(主角先手首掷命中 20)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 314 | 2354 | "nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)" | int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false)) | nat20 行留档 roll=20+crit 标记。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 315 | 2356 | "nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" ↩ % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)] | int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk) | nat20 普通分量×2(暴击只乘普通分量)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 316 | 2361 | "削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)" | int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2 | 削弱重复授予:层数保持 1、剩余结算数重置(续时语义)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 317 | 2363 | "削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)" | int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude) | 削弱单层池读数=行值。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 318 | 2374 | "AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" ↩ % tiers.size() | tiers_valid and tiers.size() >= 3 | AC11 攻击拍逐拍判定全落档域。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 319 | 2375 | "折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" ↩ % str(tier_distinct.keys()) | tier_distinct.size() >= 2 | 折扣档实际流动(≥2 档可见)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 320 | 2392 | "全战斗流零 v=0 伤害行(伤害下限全局生效)" | all_positive | 全战斗流零 0 值伤害行(伤害下限全局生效)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 321 | 2393 | "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)" | attack_ok | 攻击类伤害行掷点读数+档位随行留档(归因)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 322 | 2394 | "棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)" | board_ok | 棋盘来源伤害行零掷点字段(必中全额)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 323 | 2398 | "棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)" | DamageMath.counter_component(2, -1, df) == 1 | 棋盘克制直伤贴底下限 1。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 324 | 2399 | "克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)" | DamageMath.counter_component(1, -3, df) == 1 | 克制修正触底穿透下限 1。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 325 | 2400 | "空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)" | DamageMath.counter_component(0, 5, df) == 0 | 空桶不吃修正恒 0(边界不受下限影响)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | + +## S24 _smoke_pool_wiring(行 2481–2487 · 4 条 · 去向:单元测试 → tests/unit/battle_settlement/roll_multiplier_test.gd · 施工组 G4) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 326 | 2481 | "单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" ↩ % base_v | int(base_row.get("normal", -1)) == base_v and base_v > 0 | 单分量基线在位(normal 分量=落地值)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 327 | 2482 | "增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" ↩ % [base_v, int(up_row.get("value", -1))] | int(up_row.get("value", -1)) == int(floor(base_v * 1.5)) | 增伤经乘区合计×1.5(直乘旁路退役)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 328 | 2484 | "乏力经乘区削减合计(%d → %d = ×0.75)" ↩ % [base_v, int(sapped_row.get("value", -1))] | int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75)) | 乏力经乘区削减×0.75。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 329 | 2486 | "硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" ↩ % [base_v, int(fortify_row.get("value", -1))] | int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v | 硬化折半方向×0.5 且<基线(翻倍 bug 退役)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | + +## S25 _smoke_board_generator(行 2514–2564 · 22 条 · 去向:单元测试 → tests/unit/core/board_gen_reward_test.gd · 施工组 G3) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 330 | 2514 | "BoardGen 行解析 (%d,0)" ↩ % L | not row.is_empty() | 棋盘生成行解析 (L,0) 非空。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 331 | 2518 | "R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)" | int(ceil_row.get("node_depth", -1)) == 4 | ceil 解析 (2,3)→(2,4)(无精确行向上取整偏难)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 332 | 2520 | "L%d 生成产物非空" ↩ % L | b1 != null | 生成产物非空。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 333 | 2523 | "L%d clearable(安全格 8 邻连通)" ↩ % L | board_generator.is_clearable(b1) | 可清性成立(安全格 8 邻连通)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 334 | 2524 | "L%d legality 全过" ↩ % L | board_generator.legality(b1, row, _db) | 合法性全过。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 335 | 2531 | "L%d inferability %.3f %s target %.2f(accept_mode=%s)" ↩ % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode] | inf >= float(row.inferability_target) or mode == "accept_best" | 出图验收:首过≥可推理目标或诚实降级 accept_best。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 336 | 2533 | "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" ↩ % L | b1.variants.is_empty() | 生成器产物零变体格(变异布置归战斗初始化第 4 步)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 337 | 2534 | "L%d 指标集输出降级标记与目标雷数(三分出口表)" ↩ % L | b1.metrics.has("degraded_product") and b1.metrics.has("target_mines") | 指标集输出降级标记与目标雷数。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 338 | 2535 | "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" ↩ % [L, mode, str(b1.degraded_product)] | b1.degraded_product == (mode == "accept_best") | 降级标记=(accept_mode==accept_best)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 339 | 2536 | "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" ↩ % [L, mode, str(b1.illegal_product)] | b1.illegal_product == (mode == "accept_worst") | 非法产物标记=(accept_mode==accept_worst)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 340 | 2537 | "L%d 降级/非法产物标记互斥(两出口不可同真)" ↩ % L | not b1.degraded_product or not b1.illegal_product | 降级/非法产物标记互斥。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 341 | 2538 | "L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" ↩ % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)] | int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines) | 实际雷数≤目标雷数(先布后删只减不增)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 342 | 2539 | "L%d attempts_used ∈ [1,%d]" ↩ % [L, int(row.max_gen_attempts)] | b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts) | 尝试次数在 [1,上限] 域内。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 343 | 2540 | "L%d 非法产物标记 = false" ↩ % L | not b1.illegal_product | 非法产物标记=false(正常出图)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 344 | 2544 | "L%d 同种子两次生成逐格相同" ↩ % L | _board_dump(b2) == _board_dump(b3) | 同种子两次生成逐格相同(确定性)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 345 | 2546 | "L%d 异种子产物不同(退化护栏)" ↩ % L | _board_dump(b2) != _board_dump(b4) | 异种子产物不同(退化护栏)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 346 | 2552 | "L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" ↩ % [L, cap_val] | cap_val >= BoardData.SPAWN_AREA_CELLS | r0 上限正式行投值在位(≥出生保障面 9)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 347 | 2553 | "L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" ↩ % [L, int(b1.metrics.r0_count), cap_val] | int(b1.metrics.r0_count) <= cap_val | 结构断言:出生展开数≤cap(重掷闸承载)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 348 | 2554 | "L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" ↩ % [L, int(b1.metrics.get("r0_cap_rejected", -1))] | int(b1.metrics.get("r0_cap_rejected", -1)) >= 0 | cap 拒绝计数指标在位。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 349 | 2555 | "L%d ring_removed=%d ≤ 剃雷域候选危险格数((3+2r)²−9——全域剃雷退役)" ↩ % [L, int(b1.metrics.get("ring_removed", 99))] | int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5 | 剃雷量≤域内候选危险格数(全域剃雷退役)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 350 | 2562 | "L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" ↩ % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"] | b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product) | cap 压线 9:出图 R₀≤9 或诚实降级非法标记(玩家永不卡死)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 351 | 2564 | "L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" ↩ % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))] | int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "" | cap 压线出图指标集完整(accept_mode+闸触发数)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | + +## S26 _smoke_variation_framework(行 2578–2603 · 9 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 352 | 2578 | "变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)" | _db.variation.rows.size() == 6 | 变异清单恰 6 行(首批四类型+落石/矿尘)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 353 | 2579 | "变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)" | _db.variation.validate().is_empty() | 变异表自校验零错误(含 global 三互锁负样本)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 354 | 2583 | "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)" | pool1_ids == ["thorn", "vine"] | L1 地牢候选池=荆棘丛+藤蔓缠绕(解锁+板型双闸)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 355 | 2587 | "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)" | pool2_ids == ["magma", "ash"] | L2 幽暗地域池=岩浆+烬覆。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 356 | 2588 | "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)" | _db.variant_pool(1, "underdark").is_empty() | 解锁门控:L1 不进幽暗地域行。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 357 | 2592 | "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)" | pool2d_ids == ["thorn", "vine"] | 板型门控:L2 地牢池被 board_types 交集排除岩浆/烬覆。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 358 | 2593 | "清单外 variant_id 返回空(读取通道在位)" | _db.variation.row_by_id("nonexistent").is_empty() | 清单外 variant_id 返回空(读取通道在位)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 359 | 2599 | "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)" | _errs_has_cross_field(vt.validate()) | 互锁负样本①:flat_damage+placement=any 被拒。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 360 | 2603 | "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒" | _errs_has_cross_field(vt.validate()) | 互锁负样本②:hide_number+trigger≠on_number_shown 被拒。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S27 _smoke_global_variant(行 2641–2749 · 30 条 · 去向:单元测试 → tests/unit/battle_settlement/variant_placement_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 361 | 2641 | "互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)" | _errs_has_cross_field(vt.validate()) | 互锁负样本③:resource_face+scope=region 被拒。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 362 | 2645 | "互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)" | _errs_has_cross_field(vt.validate()) | 互锁负样本④:global+hide_number 被拒。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 363 | 2649 | "互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 整图恒在)" | _errs_has_cross_field(vt.validate()) | 互锁负样本⑤:global+on_open 被拒。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 364 | 2653 | "互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)" | _errs_has_cross_field(vt.validate()) | 互锁负样本⑥:global+closed_only 被拒。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 365 | 2662 | "互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)" | domain_hit | 互锁负样本⑦:resource_face 值 51 越域被拒。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 366 | 2667 | "门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)" | pool_boss_ids == ["magma", "ash"] | 门控三维:L2 boss 池=region 两行(贫矿脉屏蔽后无 global 行)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 367 | 2671 | "门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)" | pool_battle_ids == ["magma", "ash"] | 门控三维:battle 节点静默跳过 global 行。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 368 | 2672 | "门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)" | _db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2 | 缺省双参调用=global 行跳过(既有口径零扰动)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 369 | 2674 | "L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)" | _db.variant_pool(1, "underdark", "boss").is_empty() | L1 Boss 免疫:解锁门控在 boss 节点同样生效。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 370 | 2675 | "全局决议:battle 节点恒无全局型" | _db.global_variant_row(2, "underdark", "battle").is_empty() | 全局决议:battle 节点恒无全局型。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 371 | 2676 | "全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)" | _db.global_variant_row(2, "underdark", "boss").is_empty() | 全局决议:L2 boss 全局池空→空行(兜底回落承载)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 372 | 2677 | "全局决议:L1 boss 全局池空 → 空行(兜底回落承载)" | _db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty() | 全局决议:L1 boss 全局池空→空行。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 373 | 2679 | "elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)" | _db.global_variant_row(2, "underdark", "elite").is_empty() | elite 前向词位:boss 同池恒空(词表位不造行)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 374 | 2685 | "L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)" | boss_ok | L2 Boss 战斗启动受理(兜底回落断言载体)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 375 | 2691 | "屏蔽后回落:Boss 图无整图全局型(board_variants 空——全局池空合法态)" | bb.board_variant_ids().is_empty() | 屏蔽后回落:Boss 图无整图全局型(合法空态)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 376 | 2696 | "屏蔽后回落:Boss 图 region 变异恰一行且在候选池内(实测 %s)" ↩ % str(boss_vids.keys()) | boss_vids.size() == 1 and (boss_vids.has("magma") or boss_vids.has("ash")) | 屏蔽后回落:Boss 图 region 变异恰一行且在候选池内。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 377 | 2698 | "屏蔽后回落:Boss 图恢复 region 变异格布置(原替换制跳过 region 的逻辑随全局池空失效)" | not bb.variants.is_empty() | 屏蔽后回落:Boss 图恢复 region 变异格布置。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 378 | 2699 | "屏蔽后回落:variant_place 恢复取号(原全局取用零取号态消失)" | crng.variant_calls > 0 | 屏蔽后回落:variant_place 恢复取号。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 379 | 2701 | "快照投影:board_variants 空态一致(横幅/角标唯一读取面——无全局型即零预告)" | _arr_str_eq(snap.get("board_variants", []), bb.board_variant_ids()) | 快照投影 board_variants 空态一致(横幅唯一读取面)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 380 | 2711 | "确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)" | _arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls | 同种子 Boss 图两遍布置/变异/取号逐位相同(region 布置路径确定性)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 381 | 2717 | "偏斜基线:正向池现行合计 77(挖掘风险批口径)" | base_sum == 77 | 资源偏斜基线:正向池合计 77。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 382 | 2720 | "off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)" | off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows) | off 态零扰动:skew=0 行集与基线逐位相同。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 383 | 2723 | "合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)" | eco._pool_weight(on_rows) == 77 | 合计守恒:偏斜后正向池合计仍 77。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 384 | 2725 | "白名单豁免:weaken/gp/action 三行偏斜后逐位不动" | wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2 | 白名单豁免:weaken/gp/action 三行偏斜后逐位不动。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 385 | 2727 | "生存族下调:heal 4→3 / defense 11→7(v=30 构造例)" | wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7 | 生存族下调构造例(heal 4→3、defense 11→7)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 386 | 2728 | "进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)" | wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9 | 进攻族上浮构造例(等比上浮守恒)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 387 | 2733 | "合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8" | eco._pool_weight(combo_rows) == 77 + 4 * 2 | 合成序:图级偏斜先乘、件级加算后加(合计 77+8)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 388 | 2734 | "合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)" | cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11 | 合成序构造例逐行值正确。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 389 | 2738 | "域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)" | eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0 | 域上界 v=50:守恒成立且逐行权重恒正。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 390 | 2749 | "确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)" | n1 == _board_resource_dump(normal2) and not n1.is_empty() | 同种子普通图布置/摇号两遍逐位相同(off 全链路确定性)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | + +## S28 _smoke_variant_placement(行 2801–2873 · 9 条 · 去向:单元测试 → tests/unit/battle_settlement/variant_placement_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 391 | 2801 | "L%d 布置段战斗启动失败" ↩ % L | false | 布置段战斗启动失败显式红。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 392 | 2804 | "L%d 布置段板非空" ↩ % L | b != null | 布置段板非空。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 393 | 2825 | "L%d 变异 id 全在候选池(门控一致性:%s)" ↩ % [L, str(pool_ids)] | not bad_id | 变异 id 全在候选池(门控一致性)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 394 | 2826 | "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" ↩ % L | single_type | 每图至多一种变异类型(单格单 id+全图同 id)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 395 | 2834 | "L%d 变异格全在初始展开区外且不吃危险格(布置域结构保障——closed_only)" ↩ % L | domain_ok | 变异格全在初始展开区外且不吃危险格。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 396 | 2841 | "L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" ↩ % [L, vcells.size(), expect_cap] | vcells.size() <= expect_cap | 变异格数≤公式上限(可挖池百分比+关卡递增)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 397 | 2844 | "L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" ↩ % L | expect_cap == 0 or vcells.size() > 0 | 公式量>0 时非零布置(极小池 round 至 0=合法零布置)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 398 | 2866 | "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" ↩ % [L, pack_max] | pack_ok | 全部变异连通片≤pack_max 格。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | +| 399 | 2873 | "L%d 同种子变异布置逐格相同(variant_place 流确定性)" ↩ % L | b2 != null and _variant_dump(b2) == _variant_dump(b) | 同种子变异布置逐格相同(variant_place 流确定性)。 | 迁单元测试(tests/unit/battle_settlement/variant_placement_test.gd) | | + +## S29 _smoke_full_battle(行 2900–2928 · 7 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 400 | 2900 | "%s 战斗在轮次上限内终局(%d 轮,%s)" ↩ % [label, run1.rounds, run1.result] | run1.terminated | 完整战斗在轮次上限内终局。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 401 | 2908 | "%s 回放断言:结算步骤流逐行相同" ↩ % label | JSON.stringify(run1.stream) == JSON.stringify(run2.stream) | 同种子两遍结算步骤流逐行相同(战斗回放断言)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 402 | 2909 | "%s 回放断言:事件流逐位相同" ↩ % label | JSON.stringify(run1.events) == JSON.stringify(run2.events) | 同种子两遍事件流逐位相同。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 403 | 2910 | "%s 结算步骤流非空(%d 行)" ↩ % [label, run1.stream.size()] | run1.stream.size() > 0 | 结算步骤流非空。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 404 | 2924 | "%s 步骤流含判定行" ↩ % label | has_verdict | 步骤流含判定行。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 405 | 2927 | "%s 主角先手攻击行必存在(2026-09-11 拍板① + 案C 地图账殿后)×%d" ↩ % [label, hero_attacks] | hero_attacks >= 1 | 主角先手攻击行必存在(先手必兑+地图账殿后)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 406 | 2928 | "%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" ↩ % label | hero_breakdown_ok | 主角攻击行携带类型键与掷点读数(归因面)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S30 _run_battle(行 2960–2961 · 1 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 407 | 2960 | "战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" ↩ % String(action_economy.phase) | action_economy.phase == "Idle" | 战斗收束后行动经济相位回 Idle(notify_combat_end 生效)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S31 _smoke_power_interval(行 3003–3055 · 5 条 · 去向:单元测试 → tests/unit/core/board_gen_reward_test.gd · 施工组 G3) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 408 | 3003 | "区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" ↩ % [interval_rows.size(), str(interval_rows.keys())] | anchors_ok and interval_rows.size() >= 1 | 区间行配对齐全且 power=floor 中点(配置推导)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 409 | 3039 | "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)" | in_domain | 24 板全区间行快照 power 在区间内(含清图槽)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 410 | 3040 | "定值行快照 power 恒等表值——区间批零外溢" | fixed_ok | 定值行快照 power 恒等表值(区间批零外溢)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 411 | 3054 | "区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)" | variance_ok | 区间行上下阶实际流动(≥2 样本须≥2 不同值)。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | +| 412 | 3055 | "定值行(lo=hi)roll 恒等表值" | fixed_rows_ok | 定值行(lo=hi)roll 恒等表值。 | 迁单元测试(tests/unit/core/board_gen_reward_test.gd) | | + +## S32 _smoke_active_refresh(行 3088–3144 · 17 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 413 | 3088 | "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)" | settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | 轮内刷新战斗启动(Boss 行多轮样本)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 414 | 3094 | "三轮停手攒 RAP=%d(round=%d rap=%d)" ↩ % [rap_banked, settlement.round_index, settlement.action_economy.rap] | settlement.round_index == 4 and settlement.action_economy.rap == rap_banked | 三轮停手攒 RAP=3×每轮 AP。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 415 | 3095 | "第 4 轮 Active 相位" | settlement.action_economy.phase == "Active" | 第 4 轮进入 Active 相位。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 416 | 3105 | "轮内第 1 次刷新受理" | ok1 | 轮内第 1 次刷新受理。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 417 | 3106 | "相位保持 Active、AP 保留不重置(ap=%d)" ↩ % settlement.action_economy.ap | settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before | 刷新后相位保持 Active、AP 保留不重置。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 418 | 3107 | "RAP -刷新价(%d)" ↩ % settlement.action_economy.rap | settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost) | 刷新扣 RAP 价(表值)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 419 | 3108 | "当场换新图(board_changed +1)" | int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before | 当场换新图(board_changed 事件+板对象更换)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 420 | 3114 | "轮内第 2 次刷新受理(RAP=%d)" ↩ % settlement.action_economy.rap | ok2 | 轮内第 2 次刷新受理。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 421 | 3120 | "第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" ↩ % settlement.action_economy.rap | not ok3 | 第 3 次刷新被拒(每轮兜底上限 2,RAP 足额仍拒)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 422 | 3122 | "V5 快照刷新计数=2(受理单源——main 副本已删)〔迭代27 T3〕" | int(settlement.presentation_snapshot().get("refresh_used", -1)) == 2 | 快照刷新计数=2(受理单源,main 副本已删)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 423 | 3124 | "V5 上限达门禁一致(can_refresh 与快照计数同源)〔迭代27 T3〕" | not settlement.can_refresh() | 上限达门禁与快照计数同源。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 424 | 3136 | "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" ↩ % [settlement.round_index, settlement.battle_result] | settlement.round_index == 5 and settlement.battle_result == "" | 拒绝后挖至 AP 耗尽自动结算进第 5 轮。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 425 | 3137 | "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" ↩ % [int(ct["safe_rows"]), int(ct["safe_events"])] | int(ct["safe_rows"]) == int(ct["safe_events"]) | 跨图 facts 完整兑现(safe_cell 行数==事件数)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 426 | 3138 | "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" ↩ % int(ct["safe_events"]) | int(ct["safe_events"]) >= 3 | 跨图挖掘 safe 事件≥3(断言非空转)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 427 | 3139 | "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)" | bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]) | 结算段内(Waiting 相位)刷新被拒。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 428 | 3143 | "轮内刷新回放断言:同种子两遍步骤流逐行相同" | JSON.stringify(rep1) == JSON.stringify(rep2) | 轮内刷新回放:同种子两遍步骤流逐行相同。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 429 | 3144 | "轮内刷新步骤流非空(%d 行)" ↩ % rep1.size() | rep1.size() > 0 | 轮内刷新步骤流非空。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S33 _smoke_difficulty_table(行 3173–3192 · 8 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 430 | 3173 | "DifficultyTable MVP 空表加载(结构位)" | _db.difficulty != null and _db.difficulty.rows.is_empty() | 难度表 MVP 空表加载(结构位)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 431 | 3176 | "spawn 受理" | enemy_system.spawn(1, 0, int(_db.hero.max_hp)) | 空表下 spawn 受理。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 432 | 3179 | "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" ↩ % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)] | int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk) | 空表零修饰:敌值=行值原样。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 433 | 3183 | "修饰后 spawn 受理" | enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)) | 修饰行注入后 spawn 受理。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 434 | 3185 | "hp 修饰 ×1.5(%d→%d,max_hp 同步)" ↩ % [int(d0.hp), int(g2.hp)] | int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp) | hp 修饰 ×1.5 且 max_hp 同步。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 435 | 3186 | "atk 修饰 ×2.0(%d→%d)" ↩ % [int(d0.atk), int(g2.atk)] | int(g2.atk) == int(d0.atk) * 2 | atk 修饰 ×2。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 436 | 3191 | "越域修饰 spawn 受理" | enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)) | 越域修饰 spawn 受理。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 437 | 3192 | "越域乘数 clamp 至 10(%d→%d)" ↩ % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)] | int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10 | 越域乘数 clamp 至 10。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S34 _smoke_damage_types(行 3202–3229 · 10 条 · 去向:单元测试 → tests/unit/battle_settlement/roll_multiplier_test.gd · 施工组 G4) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 438 | 3202 | "goblin 护甲 = light(轻甲——教学位)" | enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light" | 地精护甲=轻甲(教学位)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 439 | 3204 | "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)" | enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light" | 熊地精护甲=轻甲(第 1 关全轻甲)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 440 | 3206 | "ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)" | enemy_system_c.spawn(1, 4, int(_db.hero.max_hp), [], -1, -1, [], "", "boss") and String(enemy_system_c.all_enemies()[0].armor_type) == "light" | 食人魔首领护甲=轻甲。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 441 | 3209 | "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)" | int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1 | 克制矩阵普通行表值在位(0/+1/−1 加法核)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 442 | 3210 | "穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)" | int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0 | 克制矩阵穿刺行表值在位。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 443 | 3211 | "魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +1(加法核)" | int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1 | 克制矩阵魔法行表值在位。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 444 | 3224 | "标错封印产生 seal_miss 归因行(×%d)" ↩ % miss_rows | miss_rows >= 1 | 标错封印产生 seal_miss 归因行。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 445 | 3225 | "标错封印兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" ↩ % negative_rows | negative_rows >= 1 | 标错封印兑付安全格资源负向字典行。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 446 | 3226 | "封印命中兑付该危险格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" ↩ % hit_rows | hit_rows >= 1 | 封印命中兑付危险格资源正向字典行。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 447 | 3229 | "标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)" | JSON.stringify(run1.rows) == JSON.stringify(run2.rows) | 标错惩罚轮回放:同种子步骤流逐行相同。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | + +## S35 _smoke_seal_irreversible_quadrants(行 3294–3388 · 21 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 448 | 3294 | "四情况 A:开局受理" | settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | 四情况场景 A 开局受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 449 | 3297 | "四情况 A:找到安全格封印目标" | not t_a.is_empty() | 场景 A 找到安全格封印目标。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 450 | 3301 | "四情况 A:封印受理" | ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}])[0].accepted | 场景 A 封印受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 451 | 3303 | "四情况 A:seal_pull 出目录拒绝(撤封印退役)" | not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog" | 场景 A:撤封印动作出目录拒绝(不可逆)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 452 | 3306 | "四情况 A:重复封印仍被拒(seal_present)" | not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "seal_present" | 场景 A:重复封印仍被拒(seal_present)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 453 | 3309 | "四情况 A:封印格直接挖开被受理(不再须先撤封印)" | not dig_a.is_empty() and bool(dig_a[0].get("accepted", false)) | 场景 A:封印格直接挖开被受理(不须先撤)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 454 | 3310 | "四情况 A:封印随挖掘隐式退役(has_seal = false)" | not ms_a.has_seal(t_a.seal_cell) | 场景 A:封印随挖掘隐式退役。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 455 | 3325 | "四情况 A:标空判定零兑付(×%d——封印已擦除不入轮末基线)" ↩ % miss_a | miss_a == 0 | 场景 A:标空判定零兑付(封印已擦除不入轮末基线)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 456 | 3326 | "四情况 A:安全格正向兑付作废(×%d——零奖励)" ↩ % grant_a | grant_a == 0 | 场景 A:安全格正向兑付作废(零奖励)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 457 | 3327 | "四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" ↩ % void_a | void_a == 1 | 场景 A:兑付作废归因行恰一次(零惩罚零奖励留痕)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 458 | 3334 | "四情况 B:开局受理" | settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | 四情况场景 B 开局受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 459 | 3337 | "四情况 B:找到危险格封印目标" | not t_b.is_empty() | 场景 B 找到危险格封印目标。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 460 | 3341 | "四情况 B:封印受理" | ms_b.settle_batch([{"action": "seal_place", "cell": t_b.seal_cell}])[0].accepted | 场景 B 封印受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 461 | 3343 | "四情况 B:封印危险格直接挖开被受理" | not dig_b.is_empty() and bool(dig_b[0].get("accepted", false)) | 场景 B:封印危险格直接挖开受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 462 | 3344 | "四情况 B:危险格已揭示(挖雷本体照常)" | ms_b.is_revealed(t_b.seal_cell) and ms_b.current_board().is_mine(t_b.seal_cell) | 场景 B:危险格已揭示(挖雷本体照常)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 463 | 3356 | "四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" ↩ % mine_hit_b | mine_hit_b >= 1 | 场景 B:挖雷惩罚照常(只受一层惩罚)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 464 | 3357 | "四情况 B:标中不发(×%d——封印已擦除不判)" ↩ % hit_b | hit_b == 0 | 场景 B:标中不发(封印已擦除不判)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 465 | 3364 | "四情况 C:开局受理" | settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | 四情况场景 C 开局受理。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 466 | 3368 | "四情况 C:安全格+危险格封印目标齐备" | not t_safe.is_empty() and not t_mine.is_empty() | 场景 C:安全格+危险格封印目标齐备。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 467 | 3387 | "四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" ↩ % [miss_c, miss_neg_c] | miss_c >= 1 and miss_neg_c >= 1 | 场景 C:标空惩罚照常(归因+负向兑付)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 468 | 3388 | "四情况 C:标中奖励照常(×%d)" ↩ % hit_c | hit_c >= 1 | 场景 C:标中奖励照常。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S36 _smoke_playtest_logger(行 3402–3435 · 8 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11;去向补正:转办行→cross_system_flow_test.gd,见维护记录) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 469 | 3402 | "会话文件开启(%s)" ↩ % path | path.contains("playtest_20260912") | 试玩日志会话文件开启(文件名含种子)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 470 | 3403 | "快照战斗启动" | settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) | 快照战斗启动。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 471 | 3407 | "每结算一快照(%d 次结算 → %d 快照)" ↩ % [2, snaps.size()] | snaps.size() == 2 | 每结算恰一快照(2 结算→2 快照)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 472 | 3414 | "快照字段齐备(缺 %s)" ↩ % str(missing) | missing.is_empty() | 快照必需字段齐备(12 键零缺)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 473 | 3415 | "快照含英雄/经济/板序号(板 seq=%d)" ↩ % int(s1.board.seq) | int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq") | 快照含英雄/经济/板序号。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 474 | 3418 | "快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" ↩ % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))] | int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)) | 快照 spawn_count 在位且等于单位数(无击杀轮全灭/空场可判别——Bug#1 回归)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 475 | 3422 | "诊断转储文件生成(%s)" ↩ % diag | diag.contains("diag_") | 诊断转储文件生成。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 476 | 3435 | "JSONL 全部行可解析(%d 行 = session + 2 结算)" ↩ % lines | lines >= 3 and parse_ok | JSONL 全部行可解析(会话+2 结算)。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | + +## S37 _smoke_encounter_roll(行 3448–3506 · 8 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 477 | 3448 | "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)" | settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) | 遭遇扫描 20 种子战斗启动(quiet 计数档)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | true | +| 478 | 3451 | "D0 区间 [1,2] 两值均出现(分布 %s)" ↩ % str(counts) | counts.has(1) and counts.has(2) | D0 区间 [1,2] 两值均出现。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 479 | 3457 | "全部计数在域内 [1,2](分布 %s)" ↩ % str(counts) | all_in_domain | 全部遭遇计数在域内 [1,2]。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 480 | 3461 | "同种子遭遇骰可回放(%s == %s)" ↩ % [str(r1), str(r2)] | r1 == r2 | 同种子遭遇骰可回放。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 481 | 3466 | "D1 战斗启动" | settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}) | D1 战斗启动。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 482 | 3468 | "D1 区间 [2,3] 域内(%d 只)" ↩ % n1 | n1 >= 2 and n1 <= 3 | D1 区间 [2,3] 域内。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 483 | 3481 | "熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" ↩ % bugbear_lead_band | settlement_d2.presentation_snapshot().enemies.size() == 1 \\ and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max") | 熊地精首遇带固定 1 不加骰(教学位)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 484 | 3506 | "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" ↩ % scan_total | scan_bad == 0 | 全深度×12 种子 spawn 全域内且≥1(空遭遇不可达——Bug#1 回归)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S38 _smoke_attack_chain_fresh_pool(行 3576–3590 · 3 条 · 去向:单元测试 → tests/unit/battle_settlement/roll_multiplier_test.gd · 施工组 G4) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 485 | 3576 | "fresh 池回归战斗启动(Boss 行)" | settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | fresh 池回归战斗启动(Boss 行)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 486 | 3582 | "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" ↩ % ticks.size() | ticks.size() >= 2 | 非施放攻击拍采样充足(≥2 拍)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | +| 487 | 3590 | "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)" | all_full | fresh 池攻击拍伤害=掷点档位足额(池空不吞伤——Bug#2 回归)。 | 迁单元测试(tests/unit/battle_settlement/roll_multiplier_test.gd) | | + +## S39 _smoke_clear_reward_chain(行 3616–3629 · 5 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 488 | 3616 | "① 完美封印清图达成(%d 轮)" ↩ % int(run.rounds) | bool(run.cleared) | 完美封印清图达成(真实流)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 489 | 3623 | "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" ↩ % dmg.size() | dmg.size() == 2 | 清图奖励双份真实兑付(两发 clear_reward 伤害行)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 490 | 3625 | "① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)" | String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份") | 第二份按身份类型走真实兑付(首份/第二份显示名区分)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 491 | 3627 | "④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" ↩ % hl.size() | hl.size() == 1 and int(hl[0].value) == 2 | 高光行 value=实际有效份数 2(不记名义值)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 492 | 3629 | "高光标记 = perfect_sweep" | String(hl[0].get("highlight", "")) == "perfect_sweep" | 高光标记=perfect_sweep。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S40 _smoke_clear_reward_zero_paths(行 3643–3690 · 11 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 493 | 3643 | "零兑付构造战斗启动" | s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}) | 零兑付构造战斗启动。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 494 | 3648 | "清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)" | snap != null and String(snap.cell_type) == "blast" | 清图槽身份=blast 型(clear_blast)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 495 | 3652 | "② 即时类首份零兑付(无存活敌)→ first_ok=false" | not first_ok | 无存活敌时首份零兑付 first_ok=false。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 496 | 3653 | "② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)" | _has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast") | 零兑付产出未生效归因行。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 497 | 3661 | "高光守卫构造战斗启动" | s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}) | 高光守卫构造战斗启动。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 498 | 3669 | "② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案" | _has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast") | 守卫路径重走首份→未生效行仍在案。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 499 | 3671 | "② 首份零兑付 → 高光行不发(不记名义值 2)" | _rows_of_type(rows_b, "highlight").is_empty() | 首份零兑付→高光行不发(不记名义值)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 500 | 3678 | "第二份被拒构造战斗启动" | s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}) | 第二份被拒构造战斗启动。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 501 | 3686 | "③ 首份 aoe 真实兑付(击杀唯一存活敌)" | _has_source_row(rows2, "damage", "clear_reward") | 首份 aoe 真实兑付(击杀唯一存活敌)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 502 | 3687 | "③ 第二份被拒产出 reward_ineffective 行(零兑付)" | _has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast") | 第二份被拒产出未生效行(零兑付)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 503 | 3690 | "④ 高光行 value = 实际有效份数 1(不记名义值 2)" | hl2.size() == 1 and int(hl2[0].value) == 1 | 高光行 value=实际有效份数 1。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S41 _smoke_refresh_provenance_guard(行 3698–3709 · 5 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 504 | 3698 | "轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)" | bool(run.refreshed) | 轮内刷新受理(三轮攒 RAP 后发起)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 505 | 3699 | "刷新图清图达成(%d 轮)" ↩ % int(run.rounds) | bool(run.cleared) | 刷新图清图达成。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 506 | 3707 | "刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" ↩ % cr | cr == 0 | 刷新图清图零 clear_reward 行(来源守卫在位)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 507 | 3708 | "刷新图清图 → 无高光行(守卫在位——实测 %d 行)" ↩ % hl | hl == 0 | 刷新图清图零高光行。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 508 | 3709 | "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" ↩ % _regular_clear_reward_rows | _regular_clear_reward_rows > 0 | 对照基线:常规清图有 clear_reward 行(守卫非全局静默)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S42 _smoke_polarity_routing(行 3750–3853 · 12 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 509 | 3750 | "极性路由构造种子可达(seed=%d——并流后行为面重锚)" ↩ % polarity_seed | polarity_seed > 0 | 极性路由构造种子可达(行为面重锚)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 510 | 3751 | "构造触达:标中真雷 + 标空安全格均入队" | bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"]) | 构造触达:标中真雷+标空安全格均入队。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 511 | 3752 | "构造触达:挖到危险格 ≥1(%d)" ↩ % int(probe["mine_events"]) | int(probe["mine_events"]) >= 1 | 构造触达:挖到危险格≥1。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 512 | 3753 | "构造触达:挖到安全格 ≥1(%d)" ↩ % int(probe["safe_events"]) | int(probe["safe_events"]) >= 1 | 构造触达:挖到安全格≥1。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 513 | 3770 | "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" ↩ % probe["safe_cells"].size() | safe_all_pos | 挖安全格只出正向兑付(效果行∈正向字典+每格≥1 行)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 514 | 3787 | "挖危险格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" ↩ % probe["mine_cells"].size() | mine_all_neg | 挖危险格只出负向兑付(效果行∈负向字典+每格≥1 行)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 515 | 3796 | "封印命中 → 该危险格资源的正向兑付行(origin 指回危险格,效果 ∈ 正向字典;action 型按行值符号辨极性)" | hit_ok | 封印命中→该危险格正向兑付行(origin 指回格,效果∈正向字典)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 516 | 3802 | "封印溢价配置在位(seal_hit_premium=125 provisional——形态 a 兑付溢价)" | prem_pct == 125 | 封印溢价配置在位(125 provisional)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 517 | 3830 | "标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" ↩ % [prem_rows, prem_pct] | prem_rows >= 1 and prem_ok | 标中兑付逐型溢价=同型正向×1.25。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 518 | 3836 | "标空惩罚零溢价(负向直伤行=固定表值原值——溢价只挂标中正向)" | miss_flat_ok | 标空惩罚零溢价(负向直伤=固定表值原值)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 519 | 3847 | "标空封印 → 封印未中归因行 + 该安全格资源负向兑付(同格同批)" | not miss_rows.is_empty() and miss_neg_ok | 标空封印→归因行+安全格负向兑付(同格同批)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 520 | 3853 | "封印判定兑付仅封印判定批两封印在案(撤封印抵消过滤零泄漏——%d 格)" ↩ % seal_srcs.size() | int(seal_srcs.size()) <= 2 | 封印判定兑付仅两封印在案(撤封印抵消过滤零泄漏)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S43 _smoke_negative_dictionary(行 3916–3998 · 8 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 521 | 3916 | "sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)" | not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \\ and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \\ and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack" | sapped 状态行在位(−25% 持续 1 上限 16 可叠)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 522 | 3920 | "effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)" | String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense" | 效果池映射收编 sapped→hero_offense(attack_up 镜像池)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 523 | 3958 | "扫描 6 板内出现五型齐备板(%s)" ↩ % str(by_type.keys()) | by_type.size() == 5 | 6 板扫描内出现五型齐备板。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 524 | 3968 | "%s 型负向 → %s 效果行(mine_hit 来源指回该格)" ↩ % [ctype, eff] | _has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff) | attack/defense/weaken 型负向→对应效果行(来源指回该格)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 525 | 3981 | "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" ↩ % [backlash_power, hp_before, hp_after] | hp_after == hp_before - backlash_power | heal 型负向→主角直伤表值(不经增益状态)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 526 | 3991 | "blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)" | alert_ok | blast 型负向→警觉状态在位(敌 AC+1)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 527 | 3996 | "action 型负向 → 下轮行动点 −1(排队)" | settlement.action_economy.pending_ap_delta() == d0 - 1 | action 型负向→下轮行动点 −1(排队)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 528 | 3998 | "action 型正负抵消 → 排队净值归零" | settlement.action_economy.pending_ap_delta() == d0 | action 型正负抵消→排队净值归零。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S44 _smoke_resource_surface(行 4024–4044 · 5 条 · 去向:单元测试 → tests/unit/battle_settlement/seal_polarity_test.gd · 施工组 G5) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 529 | 4024 | "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" ↩ % digchain_bad | digchain_bad == 0 | chain/dig 揭示事件恒携带资源标记(雷安同池)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 530 | 4025 | "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" ↩ % initial_count | initial_bad == 0 and initial_count >= 1 | initial 揭示事件(封锁格)资源标记恒 false。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 531 | 4026 | "危险格揭示携带资源(mine ×resource=true %d——危险格不再恒无)" ↩ % mine_with_resource | mine_with_resource >= 1 | 危险格揭示携带资源(×resource=true≥1)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 532 | 4042 | "封锁格(R₀)身份查询 = null(不入摇号清单)" | settlement.query_reward_identity(r0_first) == null | 封锁格(出生区)身份查询=null。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | +| 533 | 4043 | "未开危险格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)" | mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null | 未开危险格身份恒可查(秘宝桥接前提)。 | 迁单元测试(tests/unit/battle_settlement/seal_polarity_test.gd) | | + +## S45 _smoke_settlement_segments(行 4050–4115 · 5 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 534 | 4050 | "segment_order 五段冻结在位(表值 = 拍板序恒等)" | Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"] | 结算五段序冻结在位(表值=拍板序恒等)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 535 | 4061 | "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)" | legacy_total == 0 | 行型封闭 10 类:已删五类零残留。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 536 | 4088 | "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)" | order_ok | 每轮行相位单调(授予→增益减益→战斗→衰减→判定)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 537 | 4114 | "案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账;连珠矢随先手段)" | mapdmg_ok | 案C 段内序:地图伤害账殿后(先手→敌方→地图账)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 538 | 4115 | "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)" | decay_verdict_ok | 衰减/判定段每结算恰一段。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S46 _smoke_skill_pool_data(行 4233–4406 · 27 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 539 | 4233 | "技能表十四行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕)" | _db.skill.rows.size() == 14 | 技能表恰 14 行(三批+Boss 半血解锁技+普通敌错峰两行)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 540 | 4235 | "普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)" | not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0 and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)) | 普攻行:倍率恒 1、冷却恒 0(池恒非空的结构前提)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 541 | 4239 | "突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)" | not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2 | 突刺行(伤害 ×1.5、冷却 2)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 542 | 4242 | "蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)" | not charge_row.is_empty() and String(charge_row.buff_ref) == "empower" | 蓄力行=纯攻击增益技(buff_ref 指向 empower)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 543 | 4245 | "撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)" | not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1 | 撕咬行(伤害 ×1.5、冷却 1)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 544 | 4248 | "毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)" | not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4 and is_equal_approx(float(venom.rider_mult), 0.75) | 毒爆行(伤害 ×2.5、冷却 4、必中附加 ×0.75)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 545 | 4256 | "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)" | venom_sole_ok | 毒爆独占性:仅蛛化卓尔精灵池引用。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 546 | 4259 | "蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)" | not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard" and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4 | 蛛网束缚行(妨害型、指向失衡、有效 1 拍、冷却 4)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 547 | 4267 | "蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)" | snare_sole_ok | 蛛网束缚独占性:仅蛛化卓尔精灵池引用。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 548 | 4269 | "失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)" | not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac" | 失衡状态行在位(主角侧 ac 轴——妨害技落点)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 549 | 4273 | "毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)" | not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \\ and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult") | 毒吐行(伤害 ×1.6、冷却 2、无附加分量)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 550 | 4277 | "盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)" | not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \\ and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult") | 盾击行(伤害 ×1.7、冷却 3、无附加分量)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 551 | 4291 | "毒吐投放面(仅 depth_band > 巨蜘蛛首遇带(%d)的巨蜘蛛族行——首遇带池不动,迭代 24 N2/Q6)〔fv24〕" ↩ % spider_lead_band | spit_sole_ok | 毒吐投放面:仅巨蜘蛛族首遇带以深的行。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 552 | 4292 | "盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)" | bash_sole_ok | 盾击投放面:仅第 3 关灰矮人族。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 553 | 4300 | "巨蜘蛛首遇带行 pool 恒 {attack:6, bite:2, charge:1}(教学位池零新招——毒吐首投在更深的带)〔fv24〕" | spider_lead_pool == {"attack": 6, "bite": 2, "charge": 1} | 巨蜘蛛首遇带行池恒 attack6/bite2/charge1(教学位零新招)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 554 | 4337 | "二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)" | pools_ok | 20 行 9 种池模板逐行=建议池行值(四元键精确比对)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 555 | 4347 | "每池恰一行普攻(空池不可能的加载校验同款)" | basic_count_ok | 每池恰一行普攻(空池不可能的结构前提)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 556 | 4364 | "逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)" | comp_ok | 逐池增益≤1 且妨害≤1(池构成纪律投放后复查)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 557 | 4369 | "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)" | sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | 卓尔 Boss 战受理(半血解锁技载体)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 558 | 4372 | "前置:Boss 压到阈值 65(=round(130×0.5))" | int(boss_u.hp) == 65 | Boss 血量被压到阈值 65(=半血线)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 559 | 4378 | "解锁归因行恰一次(×%d——闩去重,二次检查零行)" ↩ % unlock_rows.size() | unlock_rows.size() == 1 | 解锁归因行恰一次(闩去重——二次检查零行)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 560 | 4380 | "解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)" | String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒" | 解锁行文本格式正确(一次性技能名 toast 路由键)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 561 | 4382 | "解锁行型 = highlight 既有行型(零行型新增——行协议封闭)" | String(unlock_rows[0].get("type", "")) == "highlight" | 解锁行型=highlight 既有行型(行协议封闭零新增)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 562 | 4396 | "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)" | rollable_ok | 全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 563 | 4400 | "蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)" | not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100 | 蓄力状态行 empower 在位(敌方输出 ×2)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 564 | 4402 | "硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)" | not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50 | 硬化状态行 fortify 在位(敌方防御 −50%)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 565 | 4404 | "effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)" | String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \\ and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense" | 效果池映射收编 empower/fortify(enemy_output/enemy_defense)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S47 _smoke_skill_pool_replay_independence(行 4419–4426 · 2 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 566 | 4419 | "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" ↩ % seed_val | seed_val > 0 | 多实例种子可达(D3 遭遇≥2 实例)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 567 | 4425 | "同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" ↩ % [rounds, int(run1.count)] | not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs) | 同种子回放各实例摇定动作序列逐位相同。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S48 _smoke_skill_pool_ev_anchor(行 4500–4500 · 1 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 568 | 4500 | "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" ↩ % [eid, measured, float(band[0]), float(band[1]), n_beats] | in_band | 三敌种 EV 抽样实测落在登记带宽内(5000 拍含保底闸长期速率)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S49 _smoke_skill_pool_death_void(行 4517–4538 · 5 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 569 | 4517 | "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" ↩ % void_seed | void_seed > 0 | 死亡打断种子可达(D2 首拍摇中特殊技)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 570 | 4523 | "死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)" | settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}) | 死亡打断战斗启动受理(主角一击必杀构造)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 571 | 4527 | "开轮已摇定本拍动作(待发 = %s)" ↩ % pending | pending != "" | 开轮已摇定本拍动作(待发非空)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 572 | 4533 | "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)" | enemy_rows == 0 | 先手击杀后第 1 轮零敌方行动行(摇定技能作废=死亡不动手)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 573 | 4537 | "作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)" | cd_sum == 0 and int(u.basic_streak) == 0 | 作废不进账:全池冷却 0 且保底计数 0。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S50 _smoke_skill_pool_validation_reject(行 4546–4587 · 13 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 574 | 4546 | "加载校验拒绝:池条目 skill_id 悬空致命" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill") | 加载校验拒绝:池条目技能 id 悬空致命。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 575 | 4549 | "加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition") | 加载校验拒绝:池普攻行数≠1(空池不可能前提)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 576 | 4555 | "加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition") | 加载校验拒绝:池增益技>1(每池增益≤1)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 577 | 4558 | "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)" | not skill_table.validate().is_empty() | 加载校验拒绝:普攻配倍率≠1(域校验锁死)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 578 | 4560 | "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)" | not skill_table.validate().is_empty() | 加载校验拒绝:普攻配冷却≠0(域校验锁死)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 579 | 4562 | "加载校验拒绝:damage_mult 越域 [1.1,16]" | not skill_table.validate().is_empty() | 加载校验拒绝:伤害倍率越域 [1.1,16]。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 580 | 4565 | "加载校验拒绝:池权重 ≤ 0" | not et2.validate().is_empty() | 加载校验拒绝:池权重≤0。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 581 | 4567 | "加载校验拒绝:空池(禁)" | not et2.validate().is_empty() | 加载校验拒绝:空池(禁)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 582 | 4573 | "加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition") | 加载校验拒绝:池妨害技>1(每池妨害≤1)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 583 | 4576 | "加载校验拒绝:妨害技缺 buff_ref(效果引用)" | not skill_table.validate().is_empty() | 加载校验拒绝:妨害技缺 buff_ref。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 584 | 4578 | "加载校验拒绝:rider_mult 越域 (0,1]" | not skill_table.validate().is_empty() | 加载校验拒绝:必中附加分量越域 (0,1]。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 585 | 4580 | "加载校验拒绝:非伤害技带必中附加分量" | not skill_table.validate().is_empty() | 加载校验拒绝:非伤害技带必中附加分量。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 586 | 4586 | "加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side") | 加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S51 _smoke_debuff_rider(行 4660–4662 · 3 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 587 | 4660 | "毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" ↩ % [venom_seed, venom_rows_seen] | venom_seed > 0 and venom_rows_seen >= 1 | 毒爆必中附加分量种子可达(伤害行样本在案)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 588 | 4661 | "毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)" | venom_check_ok | 毒爆伤害行逐拍精确复算:终值=掷点主分量+必中附加(rider 键只随授权行出现)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 589 | 4662 | "蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" ↩ % snare_seed | snare_seed > 0 | 蛛网束缚落主角:施放后掷点阈值=基准 AC+失衡幅度(端到端)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S52 _smoke_level_graph(行 4671–4943 · 54 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 590 | 4671 | "LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误" | _db.level.validate().is_empty() | 关卡表图合法性校验零错误(L1 单链与 L2 图同套算法)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 591 | 4677 | "L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)" | not l2.is_empty() and _level_active_nodes(2).size() == 10 | L2 节点数=10(入口+三层中间+商店房+Boss)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 592 | 4684 | "L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)" | int(tcount.battle) == 6 and int(tcount.boss) == 1 | L2 普通战斗节点=6 且 Boss 恒一。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 593 | 4685 | "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)" | int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1 | L2 恢复/奖励/商店房节点各 1。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 594 | 4686 | "L2 真实岔路 = %d 处(≥2)" ↩ % branch | branch >= 2 | L2 真实岔路≥2 处。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 595 | 4692 | "图校验正例:两岔合流小图零错误" | lt.validate().is_empty() | 图校验正例:两岔合流小图零错误。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 596 | 4696 | "图校验拒绝:节点编号重复(关卡内唯一定位地址)" | _has_err_category(lt.validate(), "dup_key") | 图校验拒绝:节点编号重复。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 597 | 4700 | "图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)" | _has_err_category(lt.validate(), "structural") | 图校验拒绝:第二无入边节点(入口唯一违例)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 598 | 4705 | "图校验拒绝:Boss 带出边(终点不可外出)" | _has_err_category(lt.validate(), "structural") | 图校验拒绝:Boss 带出边(终点不可外出)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 599 | 4709 | "图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)" | _has_err_category(lt.validate(), "structural") | 图校验拒绝:回边(连线只朝前进方向⇒无环)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 600 | 4713 | "图校验拒绝:出边指向不存在编号(悬空致命)" | _has_err_category(lt.validate(), "dangling_next") | 图校验拒绝:出边指向不存在编号(悬空致命)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 601 | 4720 | "图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)" | _has_err_category(orphan_errs, "structural") | 图校验拒绝:孤儿节点(入口不可达)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 602 | 4724 | "L2·满配 min 路径通关(实测:%s)" ↩ % win_min.result | win_min.result == "victory" | L2 满配 min 路径通关。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 603 | 4725 | "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)" | win_min.boss_depth == 9 | L2 Boss 以编号 9 解析(终点编号最大)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 604 | 4726 | "min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" ↩ % str(win_min.visited_ids) | win_min.visited_ids == [0, 1, 3, 7, 8, 9] | min 路径行进序列逐位正确(存储拍板模板)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 605 | 4727 | "选路口发生 %d 次(每节点兑付后——零自动推进)" ↩ % win_min.choices | win_min.choices >= 3 | 选路口发生≥3 次(每节点兑付后零自动推进)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 606 | 4735 | "L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" ↩ % MAX_PATH_SEED | win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9] | max 路径通关且行进序列逐位正确(存储种子)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 607 | 4742 | "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)" | runner.start_level(2, 42, {}, 0) | 手动驱动 L2 启动受理(固定模板 0)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 608 | 4749 | "首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)" | String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice() | 首战胜利后释放战斗并进选路态(不再自动推进)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 609 | 4751 | "入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)" | runner.next_candidate_ids() == [1, 2] | 入口岔路候选=[1,2](出度≤2 拍板模板)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 610 | 4752 | "候选外编号拒绝且仍待选(拒绝语义)" | not runner.pick_next_node(8) and runner.awaiting_choice() | 候选外编号拒绝且仍待选(拒绝语义)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 611 | 4753 | "选编号 2(奖励点)受理" | runner.pick_next_node(2) | 选编号 2(奖励点)受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 612 | 4754 | "到达奖励点:直通兑付(确认前不出选路口)" | String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice() | 到达奖励点直通兑付(确认前不出选路口)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 613 | 4756 | "兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]" | runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6] | 兑付确认后才出选路口且候选=[5,6]。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 614 | 4758 | "选编号 6(巨蜘蛛巢群)开战" | runner.pick_next_node(6) and runner.current_battle() != null | 选编号 6 开战。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 615 | 4783 | "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷" | green_ok | L2 首遇位完美档 12 种子恒零踩雷。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 616 | 4784 | "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" ↩ % green_wins | green_wins >= 7 | L2 首遇战胜局≥7/12(多数胜口径)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 617 | 4785 | "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" ↩ % green_min_hp | green_min_hp > 0 | L2 首遇战胜后 HP 下探读数>0(绿档记录)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 618 | 4801 | "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (2,row_kind,depth_band))" | cross_ok | L2 全部战斗/Boss 节点跨表行集完整。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 619 | 4809 | "巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" ↩ % l2_spider_band | l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \\ and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max") | 巨蜘蛛首遇带行集=恰一行固定 2 只不加骰。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 620 | 4816 | "蛛化卓尔精灵 Boss 行集(2,boss,%d)= {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" ↩ % _boss_depth(2) | l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward" | 蛛化卓尔 Boss 行集=恰一行且魔法甲。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 621 | 4824 | "首批混编行集 (2,battle,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)〔fv18〕" | mix_shape_ok | 首批混编行集 (2,battle,5) 双行且行序正确。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 622 | 4829 | "首批混编行集 (2,battle,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])〔fv19〕" ↩ % mix_sum_max | mix_sum_max <= 4 | 混编行集静态最坏骰和≤4(实例总数守卫)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 623 | 4831 | "首批混编新行 (2,battle,5,giant_spider) 行值 = 复制巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)" | int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \\ and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2 | 混编新行行值=复制巢群行(零数值新增)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 624 | 4840 | "L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)" | not l3.is_empty() and _level_active_nodes(3).size() == 10 | L3 节点数=10(L2 同模板)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 625 | 4847 | "L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)" | int(tcount3.battle) == 6 and int(tcount3.boss) == 1 | L3 战斗节点=6 且 Boss 恒一。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 626 | 4848 | "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)" | int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1 | L3 恢复/奖励/商店房节点各 1。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 627 | 4849 | "L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" ↩ % branch3 | branch3 == 3 | L3 真实岔路=3 处(五岔环廊层宽 5)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 628 | 4859 | "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (3,row_kind,depth_band))" | cross_ok3 | L3 全部战斗/Boss 节点跨表行集完整。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 629 | 4866 | "灰矮人首遇带(3,battle,%d——扫表取最低带)行集 = {duergar} 恰一行、首行重甲、固定 1 只不加骰〔fv23〕" ↩ % l3_lead_band | l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \\ and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \\ and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max") | 灰矮人首遇带行集=恰一行重甲固定 1 只。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 630 | 4874 | "灰矮人石君王 Boss 行集(3,boss,%d)= {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" ↩ % _boss_depth(3) | l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy" | 石君王 Boss 行集=恰一行重甲(甲型教学梯度收束)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 631 | 4898 | "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷" | green3_ok | L3 首遇位成长代理档完美档 12 种子全胜零踩雷。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 632 | 4899 | "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" ↩ % green3_min_hp | green3_min_hp > 0 | L3 首遇战胜后 HP 下探读数>0。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 633 | 4909 | "混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)" | mix_sys.spawn(1, 5, int(_db.hero.max_hp)) | 混编行集 spawn 受理(正向)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 634 | 4911 | "混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)" | mix_units.size() == 3 | 混编实例总数=各行数量求和(守卫内)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 635 | 4912 | "混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)" | String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear" | 混编生成序=文件行序(行序即契约)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 636 | 4914 | "混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)" | String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3 | 混编各行行值独立投影(同场不同种不同值)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 637 | 4919 | "行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)" | not over_sys.spawn(1, 6, int(_db.hero.max_hp)) | 两行各定值 3 总 6>4 的 spawn 被拒(行集纪律 fail-fast 运行面)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 638 | 4924 | "混编断言段注入行全量还原(表回 20 行——首批混编投放后基线行数,迭代 18 卡⑤;后续断言面零污染)" | int(_db.enemy.rows.size()) == 20 | 混编注入行全量还原(表回 20 行零污染)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 639 | 4931 | "可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)" | vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1 | L2 入口停点就位(defer_entry 无当前节点)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 640 | 4938 | "可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕" | vis_cand_in | 入口停点候选集⊆类型可见集(下一跳恒可见)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 641 | 4939 | "可见性冒烟:L2 Boss(编号 %d)恒在可见集(终点不入雾)〔fv3〕" ↩ % _boss_depth(2) | vis_ids.has(_boss_depth(2)) | L2 Boss 恒在可见集(终点不入雾)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 642 | 4941 | "可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" ↩ % vis_n | vis_n == 2 | 可见层数 N 读数据表(代码零硬编码)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 643 | 4942 | "可见性冒烟:入口锚层 0 时层距 3/4 的节点 7/8 入雾(非豁免——入口 BFS 层差 > N)〔fv1〕" | not vis_ids.has(7) and not vis_ids.has(8) | 层距超 N 的节点 7/8 入雾(非豁免)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S53 _smoke_gp_wallet(行 4969–5009 · 9 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 644 | 4969 | "GP 到账:L1 启动(普通战档)" | runner.start_level(1, 42) | GP 到账场景 L1 启动受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 645 | 4979 | "L1 首战胜利(battle_finished gp_dropped 载荷在案)" | fin.has("p") and String(fin["p"].get("result", "")) == "victory" | L1 首战胜利(完成信号 gp_dropped 载荷在案)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 646 | 4982 | "L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" ↩ % [gp_dropped - escrow_sum, escrow_sum] | gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4 | L1 普通战 gp_dropped 减 escrow 落在掷定+关级加值窗口。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 647 | 4988 | "钱包恰入账一次(gain 事件 ×%d)" ↩ % gains.size() | gains.size() == 1 | 钱包恰入账一次。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 648 | 4990 | "入账金额 = gp_dropped 载荷(%d)" ↩ % gp_dropped | int(gains[0]["amount"]) == gp_dropped | 入账金额=gp_dropped 载荷。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 649 | 4991 | "钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" ↩ % runner.gp | runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"]) | 钱包读数=入账后投影(破财负值经钳制≥0)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 650 | 4999 | "L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)" | boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)}) | L2 Boss 战启动受理(Boss 档掷定+关级加值)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 651 | 5005 | "L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" ↩ % String(boss.battle_result) | fin_b.has("p") | L2 Boss 战终局到达(gp_dropped 战斗末掷定上抛)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 652 | 5008 | "L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" ↩ % [gp_b - escrow_b, escrow_b] | gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11 | L2 Boss gp_dropped 减 escrow 落在 Boss 档窗口。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S54 _smoke_gp_escrow(行 5041–5108 · 12 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 653 | 5041 | "GP 袋(gp 型安全格)可挖掘局种子可达(seed=%s)" ↩ % str(scan.get("seed", -1)) | not scan.is_empty() | GP 袋可挖掘局种子可达。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 654 | 5054 | "GP局战斗终局(先手 999 收束)" | settlement2.battle_result == "victory" | GP 局战斗终局(先手收束)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 655 | 5059 | "GP 袋兑付行在案(GP 袋 +3 → 战斗内缓冲,origin 指回来源格)" | gain_ok | GP 袋兑付行在案(+3 入战斗内缓冲,来源指回格)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 656 | 5060 | "escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" ↩ % int(settlement2.escrow) | int(settlement2.escrow) >= 3 | escrow 累计缓冲≥3(战斗域中间态不入钱包)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 657 | 5062 | "格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" ↩ % [gp_d, int(settlement2.escrow)] | gp_d - int(settlement2.escrow) >= 2 and gp_d - int(settlement2.escrow) <= 4 | 格面兑付计入 gp_dropped(战斗末一次到账随上抛即弃)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 658 | 5069 | "破财直验战斗启动" | settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"}) | 破财直验战斗启动受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 659 | 5074 | "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)" | int(settle_l.escrow) == -6 | 破财 ×3 → escrow −6(负向缓冲记账零钱包触达)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 660 | 5079 | "破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" ↩ % [ gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1] | gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1 | 破财压制 gp_dropped 负值上抛(掷定本身不受污染)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 661 | 5089 | "clamp 场景:L1 启动(冒险钱包开局 0)" | runner.start_level(1, 4242) | clamp 场景 L1 启动(冒险钱包开局 0)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 662 | 5101 | "破财压制入账载荷为负(gp_dropped=%d)" ↩ % gp3 | gp3 < 0 | 破财压制入账载荷为负。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 663 | 5106 | "破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" ↩ % gp3 | gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0 | 破财入账恰一次且投影读数 0(钳制后归零)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 664 | 5108 | "钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" ↩ % [runner.gp, int(ev_box["min_gp"])] | runner.gp == 0 and int(ev_box["min_gp"]) >= 0 | 钱包全程不透支(事件投影最小读数≥0)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S55 _smoke_shop_room(行 5169–5244 · 22 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 665 | 5169 | "商店房到达种子可达(seed=%s——进店 GP ≥ 16)" ↩ % str(shop_run.get("seed", -1)) | not shop_run.is_empty() | 商店房到达种子可达(进店 GP≥16)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 666 | 5173 | "L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)" | String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8 | L2 行进到商店房(Boss 前必经位)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 667 | 5175 | "到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)" | not runner.awaiting_choice() and runner.next_candidate_ids().is_empty() | 到店开店态(离开前不出选路口)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 668 | 5179 | "三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)" | prods.size() == 3 | 三商品全陈列(无刷新无摇号)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 669 | 5181 | "L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)" | int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6 | L2 首购现价=基价+关级加值。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 670 | 5185 | "购买 atk_up 受理(现价 6)" | runner.buy_product("atk_up") | 购买 atk_up 受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 671 | 5186 | "钱包扣现价 6(%d → %d)" ↩ % [gp0, runner.gp] | runner.gp == gp0 - 6 | 钱包扣现价 6。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 672 | 5187 | "统一冒险加算区 atk +1(bonus={atk,max_hp,armor_class} 账本)" | int(runner.run_ledger.bonus.get("atk", -1)) == 1 and int(runner.run_ledger.bonus.get("max_hp", -1)) == 0 and int(runner.run_ledger.bonus.get("armor_class", -1)) == 0 | 统一冒险加算区 atk +1(RunLedger 账本)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 673 | 5190 | "atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)" | int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1 | atk_up 二购现价=步长×已购档(现价现算)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 674 | 5198 | "购买 hp_up 受理(现价 4)" | runner.buy_product("hp_up") | 购买 hp_up 受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 675 | 5199 | "钱包扣 4(%d)" ↩ % runner.gp | runner.gp == gp0 - 6 - 4 | 钱包扣 4。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 676 | 5200 | "生命上限 +3(%d → %d——加算区 max_hp 轴)" ↩ % [mhp_before, runner.effective_max_hp()] | runner.effective_max_hp() == mhp_before + 3 | 生命上限 +3(加算区 max_hp 轴)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 677 | 5201 | "当前生命同步 +3 封顶新上限(%d → %d)" ↩ % [hp_before, runner.hero_hp] | runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp()) | 当前生命同步 +3 封顶新上限。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 678 | 5203 | "购买 armor_up 受理(现价 6)" | runner.buy_product("armor_up") | 购买 armor_up 受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 679 | 5204 | "AC = 10+1(armor_class 加算区轴,无百分比通道)" | runner.effective_ac() == int(_db.hero.armor_class) + 1 | AC=10+1(armor_class 加算区轴无百分比通道)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 680 | 5219 | "余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" ↩ % [gp_dry, hp_dry] | not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry | 余额不足购买被拒(零扣费零档位变动)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 681 | 5222 | "离开商店 → 回选路口(候选 [9]——零自动推进收口)" | runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9] | 离开商店回选路口(候选 [9] 零自动推进收口)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 682 | 5225 | "主角攻击生效值 = 2+1(基础表值 + 加算区)" | runner.effective_atk() == int(_db.hero.atk) + 1 | 主角攻击生效值=基础表值+加算区。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 683 | 5226 | "选 Boss(节点 9)开战" | runner.pick_next_node(9) and runner.current_battle() != null | 选 Boss 开战。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 684 | 5230 | "战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" ↩ % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys] | int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys | 战斗开始参数=基础表值+加算区(按实际购买笔数推导)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 685 | 5242 | "冒险收束(Boss 战终局:%s)" ↩ % String(fin_end.get("p", {}).get("result", "")) | fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"] | 冒险收束到达(Boss 战终局)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 686 | 5244 | "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)" | runner.gp == 0 | 冒险收束钱包清零(局内货币生命周期终点)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S56 _smoke_shop_graph(行 5273–5292 · 6 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 687 | 5273 | "L%d 商店房恰 1 且 node_depth=8(next=[9])" ↩ % L | shops.size() == 1 and int(shops[0]) == 8 | 商店房恰 1 且编号 8(next=[9])。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 688 | 5274 | "L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" ↩ % L | boss_id == 9 and boss_id == max_id | Boss=9=全图最大编号。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 689 | 5280 | "L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" ↩ % [L, paths.size()] | paths.size() >= 2 and via_shop | 全部入口→Boss 路径穿商店房(必经位 DFS 枚举)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 690 | 5289 | "(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" ↩ % L | bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L) | (·,boss,9) Boss 行跨表精确存在。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 691 | 5290 | "(%d,battle,8) 商店房无板/战斗行集残留(陈旧行已清理)" ↩ % L | not bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L) | (·,battle,8) 商店房无板/战斗行集残留。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 692 | 5291 | "L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)" | bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8") | L1 行集零改动(教学期最小修订面)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S57 _smoke_shop_pool(行 5317–5450 · 27 条 · 去向:单元测试 → tests/unit/core/level_shop_economy_test.gd · 施工组 G8) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 693 | 5317 | "池模式商店房场景可达(seed=%s)" ↩ % str(scen.get("s", -1)) | not scen.is_empty() | 池模式商店房场景种子可达。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 694 | 5325 | "进店抽 3(货架 %d 卡——同构交互)" ↩ % first_shelf_a.size() | first_shelf_a.size() == 3 | 进店抽 3(货架 3 卡同构交互)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 695 | 5331 | "刷新首价=基价(%d)" ↩ % cost0 | cost0 == int(_db.adventure.shop_refresh_cost_base) | 刷新首价=基价。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 696 | 5332 | "基础免费 0 次(Q2——天赋增益位后续添加)" | r8.shop_refresh_free_left() == 0 | 基础免费 0 次。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 697 | 5334 | "GP 刷新受理(gp=%d cost=%d)" ↩ % [gp0, cost0] | r8.shop_refresh() | GP 刷新受理。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 698 | 5335 | "GP 刷新扣费(%d→%d)" ↩ % [gp0, int(r8.gp)] | int(r8.gp) == gp0 - cost0 | GP 刷新扣费。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 699 | 5336 | "刷新爬价 ×curve(%d)" ↩ % int(r8.shop_refresh_cost()) | int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve))) | 刷新爬价×curve 现算。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 700 | 5344 | "同种子同货架逐位(确定性重放承载——断点货架续玩语义)" | same_shelf | 同种子同货架逐位(断点货架续玩=确定性重放)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 701 | 5356 | "货架 kind 在封闭词表或保底 relic 卡(%s)" ↩ % ckind | is_relic or shelf_kinds.has(ckind) | 货架 kind 在封闭词表或保底 relic 卡。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 702 | 5363 | "首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" ↩ % [ckind, int(c.get("price", -1)), expect_p] | int(c.get("price", -1)) == expect_p | 首购现价=ceil(基价×关序曲线)(核心现算呈现零复算)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 703 | 5371 | "可购卡在架(GP %d——种子扫描保证首购空间)" ↩ % int(r8.gp) | not buy_c.is_empty() | 可购卡在架(种子扫描保证首购空间)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 704 | 5379 | "购买受理(pool 口——%s\|%s)" ↩ % [kind, tier] | r8.buy_pool_item(kind) | 购买受理(pool 口)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 705 | 5380 | "钱包扣现价(%d → %d)" ↩ % [gp0, int(r8.gp)] | int(r8.gp) == gp0 - int(buy_c.get("price", 0)) | 钱包扣现价。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 706 | 5381 | "加算区 +令牌档位件值(池件=奖励件本体——%s\|%s %+d)" ↩ % [kind, tier, int(_db.adventure.curio_value(kind, tier))] | int(a8.run_ledger.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.curio_value(kind, tier)) | 加算区+令牌档位件值(池件=奖励件本体)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 707 | 5383 | "共享池账本 -1(跨渠道单点——taken %d → %d)" ↩ % [taken0, int(a8._curio_taken.get(kind, 0))] | int(a8._curio_taken.get(kind, 0)) == taken0 + 1 | 共享池账本 −1(跨渠道单点)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 708 | 5393 | "A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" ↩ % expect_p2 | int(second.get("price", -1)) == expect_p2 | A 链爬价=公式含 A^1(进店第 2 购)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 709 | 5395 | "同 visit 第二购受理(A 链软闸在价不在限)" | r8.buy_pool_item(String(second.get("kind_id", ""))) | 同 visit 第二购受理(A 链软闸在价不在限)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 8002 | — | "A 链爬价场景未构造(抬 GP 后货架仍无可购第二卡)——场景绑定失败" | false | 场景绑定失败显式红(核代码增补)——白盒抬 GP 后货架必无可购第二卡时红。 | 核代码增补(tests/unit/core/level_shop_economy_test.gd) | | +| 710 | 5403 | "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)" | not cut_seen | 切边件零入店(40 次重抽零 GP 袋/aux)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 711 | 5414 | "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)" | not attack_seen | 库存尽剔牌库(池消耗即上限跨渠道共库)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 712 | 5416 | "池空消费拒绝(handler 零副作用——买光即枯竭硬闸)" | not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before | 池空消费拒绝(handler 零副作用买光即枯竭)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 713 | 5420 | "商店消费口受理(_apply_curio_pick 同口——真单点库)" | a8._consume_pool_purchase("attack", "common") | 商店消费口受理(真单点库)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 714 | 5426 | "商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)" | not reward_attack_seen | 商店购买进奖励牌库摘牌(买一件=奖励面少一件)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 715 | 5432 | "离开商店 → 回选路口(货架态清零落档——零自动推进收口)" | r8.continue_after_node() and r8.awaiting_choice() | 离开商店回选路口(货架态清零落档)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 716 | 5434 | "断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)" | bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty() | 断点扩键 shop_shelf 在案(离开店=空架合法态)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 717 | 5436 | "断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)" | bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0 | 断点扩键 shop_visit_buys 在案(离开店清零)。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 718 | 5442 | "终局池读数六键出讫(run_adventure pool 面——四池种+T5 专长凭证+T6 heal 行,读数只锚一次载体)" | ps.size() == 6 | 终局池读数六键出讫。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | +| 719 | 5450 | "终局池余量在域 [0,配额](%s)" ↩ % str(ps) | ps_ok | 终局池余量在 [0,配额] 域内。 | 迁单元测试(tests/unit/core/level_shop_economy_test.gd) | | + +## S58 _smoke_nine_types_gp_stream(行 5486–5524 · 6 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 720 | 5486 | "格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)" | RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item") | 格面资源十型封闭(gp 与秘宝件在册)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 721 | 5501 | "奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)" | cell_rows == 18 | 奖励字典恰 18 行(九型×正负)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 722 | 5507 | "危险格负向直伤 %s power=%d(卡⑧投值)" ↩ % [rid, int(mine_power[rid])] | int(row.get("power", -1)) == int(mine_power[rid]) | 危险格负向直伤行 power=投值(三行逐行)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 723 | 5520 | "正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" ↩ % pos_weight | pos_all_in_pool and neg_all_zero | 正向行权重≥1 全在池、负向行权重恒 0 不入池。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 724 | 5522 | "gp 型两行(GP 袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)" | gp_pos == 2 and gp_neg == 0 | GP 型两行权重(正向 2 入池/负向 0 不入池)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 725 | 5524 | "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)" | rng.counter() == 0 | derive_at 纯函数不推计数器(全局计数恒 0)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S59 _smoke_meta(行 5532–5545 · 4 条 · 去向:单元测试 → tests/unit/data_config/table_structure_guard_test.gd · 施工组 G2) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 726 | 5532 | "MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)" | meta_tbl != null and meta_tbl.validate().is_empty() | 元进度表非空且自校验零错。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 727 | 5534 | "MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)" | meta_tbl.boss_keystone.size() == 3 and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2 | 行族规模 3/3/2(Boss/换算/同调刷新升级)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 728 | 5540 | "conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)" | attrs_ok | 换算 attr 词表封闭恰全(三属性轴)。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | +| 729 | 5545 | "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)" | boss_ok | Boss 钥石 L1–L3 行连续且首杀≥重复杀。 | 迁单元测试(tests/unit/data_config/table_structure_guard_test.gd) | | + +## S60 _smoke_forge_axes(行 5559–5707 · 33 条 · 去向:单元测试 → tests/unit/core/meta_progression_forge_test.gd · 施工组 G9) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 730 | 5559 | "hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕" | not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero) | 主角静态表无两类型轴字段(轴所有权移交锻造配置)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 731 | 5562 | "锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕" | _db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light" | 锻造配置默认值 normal/light(常驻持有与解锁解耦)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 732 | 5566 | "spawn 受理(三参形态——两轴缺省回退面)" | uc.spawn(1, 0, int(_db.hero.max_hp)) | spawn 三参缺省形态受理(回退面)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 733 | 5567 | "主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕" | String(uc.hero_unit().get("attack_type", "")) == "normal" and String(uc.hero_unit().get("armor_type", "")) == "light" | 主角单位快照两轴=锻造配置默认值。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 734 | 5577 | "长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕" | mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 and sv2.keystone_balance() == 80 | 长剑首购:扣 20 钥石、等级 0→1。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 735 | 5581 | "购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕" | sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80 | 购买持久(重载后等级与余额不变)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 736 | 5584 | "长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕" | mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0 | 长剑二级购扣 30;轻甲独立零扰动。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 737 | 5591 | "余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕" | not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 and sv2d.forge_routes_snapshot() == fr_dry | 余额不足购买拒绝且零副作用(三域零变化)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 738 | 5596 | "已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕" | not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear") | 已满级拒购/未解锁拒购(上限闸+解锁闸)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 739 | 5599 | "通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕" | mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff") and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward") | 通道一:长剑练满派生解锁长枪/法杖;轻甲未满重甲/魔法甲仍锁。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 740 | 5602 | "派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕" | mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 and sv2.keystone_balance() == 30 | 派生解锁后长枪可购(首购 20)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 741 | 5609 | "通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕" | not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward") | 通道二:一次性解锁价 200 直购开(未练满也可买)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 742 | 5614 | "直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕" | mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") and sv3.keystone_balance() == 300 | 直购后练满不重复收(已解锁再直购拒绝零收费)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 743 | 5619 | "不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕" | mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff") and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear") | 不设串联门槛(法杖直购开长枪零状态不阻)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 744 | 5624 | "两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕" | mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 and sv3.forge_route_level("ward") == 1 | 两币分工:购买只扣钥石(GP 零触碰)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 745 | 5629 | "选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕" | mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" and sv3.keystone_balance() == ks6 | 选中切换免费(余额零动写存档即盘)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 746 | 5632 | "切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕" | sv3.forge_route_level("light") == 5 | 切换后旧路线练度保留(回家待命只不生效)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 747 | 5635 | "未解锁路线拒选零副作用〔锻造工坊批 V6〕" | not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword" | 未解锁路线拒选零副作用。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 748 | 5638 | "类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕" | mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" and mp2d.hero_armor_type() == "light" | 类型轴当前生效值随选中走(未解锁档回退默认)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 749 | 5647 | "build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕" | String(bp_params.get("attack_type", "")) == "normal" and String(bp_params.get("armor_type", "")) == "light" | 开局参数构造注入默认值(树钥匙未点亮新档)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 750 | 5651 | "长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)" | mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear") | 长枪直购解锁+首购+选中(夹具)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 751 | 5654 | "build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕" | String(bp_sel.get("attack_type", "")) == "pierce" and String(bp_sel.get("armor_type", "")) == "light" | 开局参数读存档选中覆盖(选中长枪→穿刺)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 752 | 5658 | "hero_override=锻造路线加值(选中长枪 Lv1→atk +1;甲 0 级→AC +0;树零分量;无 hit 键)〔锻造工坊批 V7〕" | int(mp4.hero_override().get("atk", -1)) == 1 and int(mp4.hero_override().get("max_hp", -1)) == 0 and int(mp4.hero_override().get("armor_class", -1)) == 0 and not mp4.hero_override().has("hit") | hero_override=锻造路线加值(只认选中、树零分量、无命中键)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 753 | 5664 | "换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕" | int(mp4.hero_override().get("atk", -1)) == 1 | 换当前武器→攻击加值随切换(待命练度不生效)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 754 | 5666 | "切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕" | mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3 | 切回长剑→加值=3 级量(旧练度保留且生效)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 755 | 5672 | "重甲直购解锁+首购+选中(三层 AC 夹具)" | mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy") | 重甲直购解锁+首购+选中(三层 AC 夹具)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 756 | 5676 | "注入参数冒险启动(两轴+override 同面)" | adr5.start_adventure(77, p5) | 注入参数冒险启动受理。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 757 | 5678 | "入战 AC 三层:甲型基础 12(重甲)+路线加值 1+局内 0〔锻造工坊批 V7〕" | int(r5.effective_ac()) == 12 + 1 | 入战 AC 三层:甲型基础 12+路线加值 1+局内 0。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 758 | 5680 | "参数面快照入 runner(attack_type=normal/armor_type=heavy)〔锻造工坊批 V6〕" | adr5.hero_attack_type == "normal" and adr5.hero_armor_type == "heavy" | 参数面快照入 runner(两轴正确)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 759 | 5683 | "冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕" | mp5.forge_select_armor("light") and adr5.hero_armor_type == "heavy" and int(r5.effective_ac()) == 12 + 1 | 冒险开始后锁死:改存档选中不影响快照与生效值。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 760 | 5692 | "enc 两轴直达 spawn 英雄快照(hero_attack 行携带 pierce 键——注入链末端)〔锻造工坊批 V5〕" | pierce_rows >= 1 | enc 两轴直达 spawn 英雄快照(攻击行携带选中类型键)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 761 | 5696 | "spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕" | uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy") and String(uc2.hero_unit().get("attack_type", "")) == "magic" and String(uc2.hero_unit().get("armor_type", "")) == "heavy" | spawn 显式两轴注入(直入英雄快照)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 762 | 5706 | "树零攻防来源复核(12 行 effects 键集无 atk/armor_class——契约 1)〔锻造工坊批 V10〕" | not tree_atk_ac | 树零攻防来源复核(12 行效果键无攻/护)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | + +## S61 _status_panel_projection(行 5727–5763 · 4 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 763 | 5727 | "状态面板开层可见(open_status 直开——P4 甲零动效路径)" | po.visible | 状态面板开层可见。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 764 | 5728 | "余额徽投影 = keystone_balance() 读数(%s)" ↩ % po._balance_badge.value_label.text | String(po._balance_badge.value_label.text) == "钥石 %d" % mp.keystone_balance() | 余额徽投影=访问器读数。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 765 | 5756 | "秘宝匣 12 行投影 = treasure_shop_status() 逐项(名称/钥石价与条件进度/解锁态——首件解锁夹具在列)〔秘宝批 V19 呈现数据源〕" | ws_ok | 秘宝匣 12 行投影=工坊状态逐项(名称/价与进度/解锁态)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 766 | 5762 | "天赋树概览聚合文案与已购节点数一致(零已购=空态句)" | tree_txt == "尚未购买任何节点(天赋树在营地主柜台)" or tree_txt.begins_with("已购 %d 项:" % owned) | 天赋树概览聚合文案与已购节点数一致(零已购=空态句)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S62 _career_overlay_projection(行 5796–5831 · 8 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 767 | 5796 | "生涯页开层可见(open_career 直开——零动效路径)" | co.visible | 生涯页开层可见。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 768 | 5797 | "总局数投影 = adventure_total() 读数(%s)" ↩ % co._total_label.text | String(co._total_label.text) == "2 局" | 总局数投影=访问器读数。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 769 | 5799 | "通关局数投影 = adventure_cleared() 读数(%s)" ↩ % co._cleared_label.text | String(co._cleared_label.text) == "1 局(三关全通)" | 通关局数投影=访问器读数。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 770 | 5804 | "各关通关次数投影 = clear_count() 逐关读数(%s)" ↩ % co._clears_label.text | String(co._clears_label.text) == " · ".join(expect_clears) | 各关通关次数投影=逐关读数。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 771 | 5806 | "Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" ↩ % [co._boss_first_label.text, co._boss_kills_label.text] | String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次" | Boss 首杀/总杀投影=标记计数+访问器。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 772 | 5808 | "钥石余额徽投影 = keystone_balance() 读数(%s)" ↩ % co._keystone_badge.value_label.text | String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance() | 钥石余额徽投影=访问器读数。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 773 | 5817 | "v5 现役档升 v6 载入成功(计数域缺省补零)" | v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0 | v5 现役档升版载入成功(计数域缺省补零)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 774 | 5828 | "旧档载入后生涯页正常显示(v5 升 v6:总局数/通关局数显示 0,既有钥石余额原样呈现)" | co_v5.visible and String(co_v5._total_label.text) == "0 局" \\ and String(co_v5._cleared_label.text) == "0 局(三关全通)" \\ and String(co_v5._keystone_badge.value_label.text) == "钥石 3" | 旧档载入后生涯页正常显示(0 值呈现+余额原样)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S63 _meta_pricing_and_buy(行 5871–5883 · 4 条 · 去向:单元测试 → tests/unit/core/meta_progression_forge_test.gd · 施工组 G9) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 775 | 5871 | "树购买受理(Lv 0→1、余额 %d−%d)" ↩ % [bal0, price0] | mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0 | 树购买受理(Lv0→1、余额扣表价)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 776 | 5874 | "购买持久(reload 后级与余额不变)" | sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0 | 购买持久(重载后级与余额不变)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 777 | 5880 | "余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)" | not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before | 余额不足 false 零副作用(三域零变化)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 778 | 5883 | "未知节点 false 零副作用(词表外出域)" | not mp_dry.tree_buy("no_such_node") | 未知节点 false 零副作用(词表外出域)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | + +## S64 _meta_injection(行 5894–5923 · 10 条 · 去向:单元测试 → tests/unit/core/meta_progression_forge_test.gd · 施工组 G9) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 779 | 5894 | "生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)" | int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0)) | 生命淬炼 2 级 → 永久强化 max_hp=Σ节点效果×等级。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 780 | 5896 | "攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)" | int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0 | 攻/AC 树零来源(override 二轴恒 0)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 781 | 5898 | "命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)" | not mp.hero_override().has("hit") | 命中线在账未注入(override 无 hit 字段)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 782 | 5901 | "注入参数冒险启动(start_adventure params 通道)" | adr.start_adventure(920251, {"hero_override": mp.hero_override()}) | 注入参数冒险启动受理。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 783 | 5904 | "首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)" | int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)) | 首战 hero_atk=表值+永久强化 flat。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 784 | 5906 | "首战 hero_max_hp 与开局生命账本 = 表值 + 注入档" | int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) and int(adr.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) | 首战上限与开局生命账本=表值+注入档。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 785 | 5911 | "白板冒险启动(无 params 实参——B1 空参数路径)" | adr_w.start_adventure(920251) | 白板冒险启动(无参数路径)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 786 | 5914 | "零强化参数冒险启动(新档 hero_override 全零)" | adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}) | 零强化参数冒险启动(新档全零)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 787 | 5918 | "白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)" | int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp) and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp()) and int(rz.effective_ac()) == int(rw.effective_ac()) | 白板成立:空参数=零强化参数=纯表值(三轴逐位)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 788 | 5922 | "ac 轴:白板恒表值、未换强化时注入零位移" | int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class) | ac 轴:白板恒表值、注入零位移。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | + +## S65 _meta_settle_and_workshop(行 5941–6109 · 42 条 · 去向:单元测试 → tests/unit/core/meta_progression_forge_test.gd · 施工组 G9) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 789 | 5941 | "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)" | bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1)) and int(drop1.get("level", 0)) == 1 | 新档首通 L1:掉落明细 {first:true, 金额=首杀值}。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 790 | 5943 | "明细 total = keystone_total(逐杀掉落全源——无二次派生加账)" | int(d1.get("total", 0)) == int(row1.get("first_kill", 0)) | 明细 total=keystone_total(无二次派生加账)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 791 | 5944 | "余额 = 累计入账(逐杀掉落合计——无重复加账)" | sv.keystone_balance() == int(row1.get("first_kill", 0)) | 余额=累计入账(无重复加账)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 792 | 5951 | "再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)" | not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)) | 再通 L1:{first:false, 金额=重复杀值}(标记置位后转小额)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 793 | 5957 | "重复杀第三轮恒定不递减(验收5——无衰减曲线)" | not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)) | 重复杀第三轮恒定不递减(无衰减曲线)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 794 | 5965 | "两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)" | not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1)) | 两关通关逐关归因(重复档+首杀档)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 795 | 5973 | "败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)" | dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before | 败北零通关摘要:零入账零标记、通关次数不变。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 796 | 5980 | "条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕" | not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye") and not mp_u.buy_treasure_unlock("ranged_pick") | 条件件误购拒绝(购买件专属)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 797 | 5983 | "余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕" | not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10 | 余额不足购买拒绝且零副作用。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 798 | 5985 | "钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕" | mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") and sv_u.keystone_balance() == 0 | 钥石购买解锁:解锁态落档+余额扣减。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 799 | 5988 | "已解锁重复购买拒绝〔秘宝批 V3〕" | not mp_u.buy_treasure_unlock("morning_order") | 已解锁重复购买拒绝。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 800 | 5990 | "条件未达不解锁(远程镐=通关 1 次)〔秘宝批 V4〕" | mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick") | 条件未达不解锁。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 801 | 5993 | "远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:通关=打通整局冒险)〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick") and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty() | 达标恰解锁一次+重复刷新幂等。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 802 | 5997 | "刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("refresh_license") | 刷新执照=Boss 击杀数达标(阈值读行值不硬编)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 803 | 6000 | "天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕" | not sv_u.item_unlocked("sky_eye") | 天眼单条件不足不解锁(AND 语义)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 804 | 6002 | "天眼双条件 AND(通关≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("sky_eye") | 天眼双条件 AND 达标解锁。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 805 | 6005 | "信标网络=真视类工具累计使用≥10(行为计数新域——use_treasure 受理成功累计)〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("beacon_network") | 信标网络=真视类工具累计使用达标解锁(行为计数新域)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 806 | 6010 | "解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕" | r_n == 5 | 解锁态持久投影一致(5 件=购买 1+条件 4)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 807 | 6013 | "摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)" | rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") and (rs[1] as Dictionary).has("keystone_drops") and int(rs[0].get("master_seed", -1)) == 15 | 摘要最新在前+元素含掉落明细副本。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 808 | 6021 | "摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)" | sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11 | 摘要上限 10 且最新在前(12 连收束截断)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 809 | 6026 | "白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)" | Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0 | 白板档同调位求值 0 且刷新次数 Y=0。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 810 | 6028 | "满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕" | mp_a.toggle_attunement("morning_order") == false | 满位禁选(Slots 0 装配拒绝)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 811 | 6033 | "Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)" | mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1 | Y 升级购买入账(Lv1→+1 次刷新)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 812 | 6038 | "通关 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕" | Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1 | 通关 1 关 Slots 恒 0(槽位唯一来源=树点亮)且 Boss 击杀计数=1。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 813 | 6041 | "树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)" | Attunement.slots(_db, sv_a) == 1 | 树上点亮同调位→Slots=1。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 814 | 6043 | "稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕" | not mp_a.toggle_attunement("sky_eye") | 稀有件入普通格拒绝(档位格校验)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 815 | 6045 | "装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)〔秘宝批 V5 换主语〕" | mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("twin_loader") == false | 装配持久写入+满位禁选。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 816 | 6047 | "卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕" | mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order") | 卸下恒可再装配(自卸自装合法面)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 817 | 6052 | "装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕" | (mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order") | 装配关系读回持久(重载投影一致)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 818 | 6056 | "充能重置场景:装配冒险启动(seed 12)〔秘宝批 V8 换主语〕" | a12.start_adventure(12, {"attunement": ["incense_stick", "sky_eye"]}) | 充能重置场景装配冒险启动。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 819 | 6058 | "充能账本按装备 active_charged 件重置(新目录表值:线香 battle 级 1 次/天眼 level 级 1 次——周期重置点归 V8 逐形态面)" | int(a12.run_ledger.attune_charges.get("incense_stick", -1)) == 1 and int(a12.run_ledger.attune_charges.get("sky_eye", -1)) == 1 | 充能账本按装备件重置(表值周期)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 820 | 6064 | "刷新双计数器——X 优先消耗(x1y2 → x0y2)" | a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2 | 刷新双计数器 X 优先消耗。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 821 | 6066 | "刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)" | a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0 and a12.refresh_curio() == false | 刷新双计数器 X 耗尽转 Y 池、池竭拒绝。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 822 | 6070 | "刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕" | a12._attune_refresh_x == 0 and a12.attune_refresh_left() == 0 | 刷新 X 每碰三选一重置(不结转)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 823 | 6081 | "开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)" | rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2 | 开局传参夹具前置(Fork 激活+Y 档表值防空转)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 824 | 6085 | "主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)" | (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) | 开新冒险参数面:同调装配=存档装配集。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 825 | 6087 | "主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)" | int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges() | 开新冒险参数面:Y 刷新池=当前等级行表值。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 826 | 6089 | "主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)" | (p_new.get("tree_rules", []) as Array) == rules_e | 开新冒险参数面:树 rule 键集=Fork 激活侧。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 827 | 6091 | "主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入" | bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool" | 开新冒险参数面:入口特有键随 extra 并入。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 828 | 6095 | "主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)" | (p_resume.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) and int(p_resume.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges() and (p_resume.get("tree_rules", []) as Array) == rules_e | 续玩参数面三键与开新冒险同源同值。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 829 | 6106 | "继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)" | adr_res.resume_adventure(bp_min, p_resume) | 续玩受理面:resume_adventure 受理三键参数面。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 830 | 6108 | "继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)" | (adr_res._tree_rules as Array) == rules_e | tree_rules 随参数面进 runner(消费面下传)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | + +## S66 _smoke_tree(行 6118–6187 · 25 条 · 去向:单元测试 → tests/unit/core/meta_progression_forge_test.gd · 施工组 G9) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 831 | 6118 | "meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕" | _db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12 | 天赋树表已加载恰 12 节点。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 832 | 6120 | "MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)" | _db.meta_tree.validate().is_empty() | 天赋树表自校验零违例(词表/Fork 结构/彩蛋上限/effects 封闭)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 833 | 6128 | "easter_ley effects 键集 ⊆ {max_hp, hit}(攻/护分量已摘除——契约 9)〔天赋树批 V8 落点〕" | ley_ok | 彩蛋行效果键集⊆{max_hp,hit}(攻/护分量已摘除)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 834 | 6135 | "schema 反例:vein 出域被拒" | not t2.validate().is_empty() | schema 反例:脉出域被拒。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 835 | 6140 | "schema 反例:fork_group 出域(fg_mastery 已退役组)被拒" | not t2.validate().is_empty() | schema 反例:Fork 组出域被拒。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 836 | 6145 | "schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕" | not t2.validate().is_empty() | schema 反例:effects.archetype 加载致命(层级纪律护栏)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 837 | 6149 | "schema 反例:requires 悬空被拒" | not t2.validate().is_empty() | schema 反例:前置引用悬空被拒。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 838 | 6154 | "树购买受理(脊柱生命 L1——树全钥石单币)" | mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 | 树购买受理(脊柱生命 L1 全钥石单币)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 839 | 6155 | "行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕" | not mp.tree_can_buy("sp_hp") | 行门槛:总等级不足时第 2 级不可购。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 840 | 6157 | "命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)" | mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1") | 命中/锻造钥匙/同调位 1 三行受理(总等级 1→4)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 841 | 6159 | "锻造工坊钥匙零币(free)" | int(_db.tree_node("forge_workshop").get("price", -1)) == 0 | 锻造工坊钥匙零币(free)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 842 | 6160 | "行门槛恰达开放(总等级 4 ≥ 4×1)" | mp.tree_can_buy("sp_hp") | 行门槛恰达当刻开放(总等级 4)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 843 | 6161 | "第 2 级购买受理(门槛恰达当刻开放)" | mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2 | 第 2 级购买受理。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 844 | 6164 | "Fork 购买=解锁+默认 A 态(一节点双态——单行承载)" | mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a" | Fork 购买=解锁+默认 A 态(一节点双态)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 845 | 6166 | "已解锁 Fork 重复购买拒绝(cap 1)" | not mp.tree_can_buy("fork_posture") | 已解锁 Fork 重复购买拒绝。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 846 | 6167 | "同态重复切换零操作(false)" | not mp.tree_toggle_fork("fg_posture", "a") | 同态重复切换零操作。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 847 | 6168 | "未解锁 Fork 切换拒绝(解锁唯一入口=购买面)" | not mp.tree_toggle_fork("fg_sense", "b") | 未解锁 Fork 切换拒绝。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 848 | 6170 | "零币切态 b(购买解锁后 A/B 可切)" | mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b" | 零币切态 b(解锁后 A/B 可切)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 849 | 6172 | "切态零消费(钥石余额差分不动)" | int(sv.keystone_balance()) == bal_pre_toggle | 切态零消费(余额不动)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 850 | 6173 | "规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)" | (mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike") | 规则键投影=激活态效果键(随切态换键集)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 851 | 6175 | "切回 A 态键集随换(六键词表在册——V11 校验面归单元段)" | mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike") | 切回 A 态键集随换。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 852 | 6178 | "彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)" | not mp.tree_can_buy("easter_ley") | 彩蛋不经购买(觉醒唯一入口=特化判定)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 853 | 6179 | "觉醒前置未满足不亮(两线未拉满)" | sv.tree_level("easter_ley") == 0 | 觉醒前置未满足不亮。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 854 | 6184 | "两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)" | sv.tree_level("easter_ley") == 1 | 两线拉满+同调位全解锁→彩蛋自动点亮(零币特化判定)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 855 | 6187 | "觉醒零消费(钥石余额不动)" | int(sv.keystone_balance()) == bal_now | 觉醒零消费(余额不动)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | + +## S67 _smoke_counter_buckets(行 6208–6241 · 6 条 · 去向:单元测试 → tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 施工组 G6) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 856 | 6208 | "V1 第1战真实 request_dig 受理(begin_battle 后 ACTIVE 相位)〔无状态化 V1〕" | dig1 | 第 1 战真实付费挖格受理(Active 相位)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 857 | 6209 | "V1 第1战付费挖格经 ctx 就地写 adventure 桶=1(无 export 搬运时点)〔无状态化 V1〕" | int(led.adventure_counters.get("adv_twin_digs", 0)) == 1 | 第 1 战付费计数经上下文就地写 adventure 桶=1。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 858 | 6215 | "V1 第2战 begin_battle 经 ctx 读到上一战持久值=1(读路径走宿主字典引用,非副本种子回灌)〔无状态化 V1〕" | EffectEngine.counter(o2.counters_ctx, "adv_twin_digs") == 1 | 第 2 战开战经上下文读到上一战持久值=1。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 859 | 6218 | "V1 第2战挖后 adventure 桶跨战累积=2(run_ledger:42 判空早退缺陷消解的回归钉面——旧副本态恒 1)〔无状态化 V1〕" | int(led.adventure_counters.get("adv_twin_digs", 0)) == 2 | 第 2 战挖后 adventure 桶跨战累积=2(副本搬运态在此红)。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 860 | 6234 | "V6 第1战标错落 battle 桶 insurance_used=1(_apply_counter_hit 静态写口经 ctx)〔无状态化 V6〕" | EffectEngine.counter(oA.counters_ctx, "insurance_used") == 1 | 第 1 战标错落 battle 桶 insurance_used=1。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | +| 861 | 6240 | "V6 battle 桶随宿主销毁:次战新编排器 fx_counters_battle 天然空,读 insurance_used=0〔无状态化 V6〕" | EffectEngine.counter(oB.counters_ctx, "insurance_used") == 0 | battle 桶随宿主销毁:次战新编排器读 0。 | 迁单元测试(tests/unit/battle_settlement/round_flow_skill_pool_test.gd) | | + +## S68 _smoke_tree_rules(行 6250–6346 · 19 条 · 去向:单元测试 → tests/unit/core/meta_progression_forge_test.gd · 施工组 G9) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 862 | 6250 | "先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕" | (fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1 | 先手压制:每关首战首轮窗命中。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 863 | 6252 | "先手压制:首轮窗外地不触发(第二轮/第二关首战首轮——「每战首轮」旧语义换代件)" | (fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 2}, Callable()) as Array).is_empty() and (fx.dispatch(ctx_p, "round_start", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty() | 先手压制:首轮窗外地不触发(第二轮/第二关首战)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 864 | 6255 | "稳健拓荒:首轮窗内受击出盾、窗外不出(shield damage_taken 同窗条件)" | (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1 and (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty() | 稳健拓荒:首轮窗内受击出盾、窗外不出。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 865 | 6271 | "锋:踩雷减半命中+限次记账(counter 0<2 闸开——damage_mult+counter_add 双行同预评估态)" | got_mult and got_add | 锋:踩雷减半命中+限次记账(闸开双行同态)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 866 | 6273 | "锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)〔无状态化 V5〕" | (fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array).is_empty() | 锋:限次耗尽全额(闸关)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 867 | 6278 | "锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕" | (fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty() | 锋:限次跨战持久(共享桶引用)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 868 | 6282 | "守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)" | orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | 守侧 rig 战斗启动(携带守侧规则)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 869 | 6294 | "守侧 rig:安全格定位(非危险格=标错负样本)" | safe.x >= 0 | 守侧 rig 安全格定位(非危险格=标错负样本)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 870 | 6295 | "守侧 rig:首轮后 queue 干净(开局 posture queue 已兑现)" | int(orch.action_economy.pending_ap_delta()) == 0 | 守侧 rig 首轮后队列干净。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 871 | 6298 | "守①:当回合标错且封印在场 → 下轮 AP+1(queue 排队——本轮 AP 逐位零变化)" | int(orch.action_economy.pending_ap_delta()) == 1 | 守①:当回合标错且封印在场→下轮 AP+1(排队)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 872 | 6303 | "守③④:旧封印重判零触发+同格最多一次(_seal_miss_seen 去重闸=「当回合标错动作」判别器)" | int(orch.action_economy.pending_ap_delta()) == 1 | 守③④:旧封印重判零触发+同格最多一次(去重闸判别器)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 873 | 6307 | "守②负样本:标中/被挖封印零触发(标错事件只在 miss 分支发射——发射面过滤承载)" | int(orch.action_economy.pending_ap_delta()) == 1 | 守②负样本:标中/被挖封印零触发(发射面过滤)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 874 | 6310 | "守⑤ rig:次战编排启动(新战新队列实例)" | orch2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | 守⑤ rig:次战编排启动(新战新队列实例)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 875 | 6316 | "守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)" | int(orch2.action_economy.pending_ap_delta()) == 0 | 守⑤:末轮判出不返(战亡即弃无跨战泄漏)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 876 | 6322 | "散未激活:商品标价原价(无 economy_spend 键)" | int(lr._spend_discounted(100)) == 100 | 散未激活:商品标价原价。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 877 | 6324 | "散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)" | int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1 | 散激活:商品购买价×80%(floor 偏向玩家下限 1)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 878 | 6328 | "聚 rig:基线编排启动(无增益侧)" | orch3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": [], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | 聚 rig:基线编排启动(无增益侧)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 879 | 6337 | "聚 rig:增益编排启动(tree_rules=economy_hoard)" | orch4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": ["economy_hoard"], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | 聚 rig:增益编排启动。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | +| 880 | 6345 | "聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)" | gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base | 聚:战斗掉落乘算×1.5(同种子配对对拍)。 | 迁单元测试(tests/unit/core/meta_progression_forge_test.gd) | | + +## S69 _smoke_battle_log_view(行 6362–6389 · 6 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 881 | 6362 | "敌方伤害行格式化(baseline 行不读退役 attach)" | enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12") | 敌方伤害行格式化(不读退役字段——卡死 bug 回归)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 882 | 6363 | "退役伏击/基线拆行文案不再出现" | not enemy_txt.contains("伏击") and not enemy_txt.contains("基线") | 退役伏击/基线拆行文案不再出现。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 883 | 6369 | "主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)" | hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■") | 主角穿刺暴击行金框(单分量 payload 不触发拆行)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 884 | 6370 | "退役三分量拆行文案不再出现" | not hero_txt.contains(" + 穿刺 ") | 退役三分量拆行文案不再出现。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 885 | 6376 | "未生效行聚合计数 ×2 呈现" | agg_txt.contains("⚠") and agg_txt.contains("×2") | 未生效行聚合计数×2 呈现(不逐行刷屏)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 886 | 6389 | "行型 %s 格式化落地(解析文本增长)" ↩ % String(row_type) | view.parsed_text().length() > before | 其余行型逐型格式化落地(解析文本增长——脚本错误静默吞断言转可见 FAIL)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S70 _smoke_global_theme(行 6398–6486 · 22 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 887 | 6398 | "V1 项目主题挂载(project.godot gui/theme/custom → main_theme.tres)" | th != null | 项目主题已挂载(工程设置指向主主题)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 888 | 6407 | "V1 主题类型覆盖 %s" ↩ % t | type_set.has(t) | 主题类型覆盖在册(15 类逐类型——含弹层预盖)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 889 | 6409 | "V1 红线 Button 类型词条零接触" | not type_set.has("Button") | Button 类型词条零接触(五态矩阵归按钮工厂)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 890 | 6417 | "V1 红线 focus 族词条零定义%s" ↩ % ("" if focus_hits == "" else "(违例:" + focus_hits + ")") | focus_hits == "" | focus 族词条零定义(红线扫描)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 891 | 6420 | "V1 滚动条槽/柄三态词条在位" | vsb.has_theme_stylebox("scroll") and vsb.has_theme_stylebox("grabber") and vsb.has_theme_stylebox("grabber_highlight") and vsb.has_theme_stylebox("grabber_pressed") | 滚动条槽/柄三态词条在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 892 | 6423 | "V1 滚动条 scroll_focus 未定义(红线)" | not th.has_stylebox("scroll_focus", "VScrollBar") and not th.has_stylebox("scroll_focus", "HScrollBar") | 滚动条 scroll_focus 未定义(红线)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 893 | 6426 | "V1 滑轨槽/填充/柄词条在位" | hsl.has_theme_stylebox("slider") and hsl.has_theme_stylebox("grabber_area") and hsl.has_theme_stylebox("grabber_area_highlight") and hsl.has_theme_icon("grabber") and hsl.has_theme_icon("grabber_highlight") and hsl.has_theme_icon("grabber_disabled") | 滑轨槽/填充/柄词条在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 894 | 6431 | "V1 LineEdit 词条在位(含占位色 font_placeholder_color)" | le.has_theme_stylebox("normal") and le.has_theme_color("font_color") and le.has_theme_color("font_placeholder_color") | 输入框词条在位(含占位色)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 895 | 6434 | "V1 SpinBox 箭头词条在位(updown 合并槽)" | sp.has_theme_icon("updown") | 微调框箭头词条在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 896 | 6437 | "V1 分隔线词条在位" | hsep.has_theme_stylebox("separator") and vsep.has_theme_stylebox("separator") | 分隔线词条在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 897 | 6440 | "V1 TooltipPanel 词条在位" | tpanel.has_theme_stylebox("panel") | 提示面板词条在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 898 | 6441 | "V1 TooltipLabel 词条在位" | tlabel.has_theme_color("font_color") and tlabel.has_theme_font_size("font_size") | 提示文字词条在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 899 | 6448 | "V2 冻结值锚定(已填充段=BG_ROOT #3A2E22,槽=暖石 #B8A582)" | fill_c.is_equal_approx(Color8(58, 46, 34)) and track_c.is_equal_approx(Color8(184, 165, 130)) | 滑轨冻结值锚定(已填充段与槽色=拍板色值)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 900 | 6451 | "V2 滑轨两段对比 %.2f:1 ≥4.5:1(拖到中点一眼可辨)" ↩ % ratio | ratio >= 4.5 | 滑轨两段对比度≥4.5:1(拖到中点一眼可辨)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 901 | 6453 | "V2 滑轨 stylebox 类型均为 StyleBoxFlat" | false | 滑轨样式盒类型均为扁平(否则显式红)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 902 | 6456 | "V3 CheckBox 四态图标非空" | cbx.get_theme_icon("checked") != null and cbx.get_theme_icon("unchecked") != null and cbx.get_theme_icon("checked_disabled") != null and cbx.get_theme_icon("unchecked_disabled") != null | 复选框四态图标非空。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 903 | 6460 | "V3 CheckButton 四态图标非空(与 CheckBox 同套 checked/unchecked 名,4.7 源码实证)" | cbt.get_theme_icon("checked") != null and cbt.get_theme_icon("unchecked") != null and cbt.get_theme_icon("checked_disabled") != null and cbt.get_theme_icon("unchecked_disabled") != null | 开关钮四态图标非空。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 904 | 6464 | "V3 PopupMenu 词条存在(弹层预盖)" | pm.has_theme_stylebox("panel") and pm.has_theme_stylebox("hover") and pm.has_theme_font_size("font_size") | 弹出菜单词条存在(弹层预盖)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 905 | 6467 | "V3 OptionButton 词条存在(预盖)" | ob.has_theme_stylebox("normal") and ob.has_theme_stylebox("hover") and ob.has_theme_stylebox("pressed") and ob.has_theme_color("font_color") | 选项按钮词条存在(预盖)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 906 | 6481 | "V4 退役实现零残留(%s)%s" ↩ % [path, "" if bad == "" else "违例:" + bad] | bad == "" | 四文件退役实现零残留(剥注释行扫旧符号)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 907 | 6483 | "V6 主题 default_font 非空(挂主题顶掉 custom_font 也不丢中文字形)" | th.default_font != null | 主题默认字体非空(挂主题不丢中文字形)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 908 | 6486 | "V6 Label 实取字体非空(中文字形防御实证)" | lbl.get_theme_font("font") != null | Label 实取字体非空(中文字形防御实证)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S71 _smoke_button_five_states(行 6499–6562 · 23 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 909 | 6499 | "V1 普通钮 %s 样式盒非空" ↩ % st | b.has_theme_stylebox_override(st) | 普通钮五态样式盒逐态非空(循环 5 态)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 910 | 6505 | "V1 普通钮 normal/hover/disabled 均为石纹九宫格贴图样式盒" | n_sb is StyleBoxTexture and h_sb is StyleBoxTexture and d_sb is StyleBoxTexture | 普通钮三态为石纹九宫格贴图样式盒。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 911 | 6509 | "V1 普通钮 hover=modulate 提亮(乙案,异于常态 WHITE)" | h_mod != Color.WHITE and h_mod.r > 1.0 | 普通钮悬停=提亮(乙案,异于常态白)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 912 | 6512 | "V1 普通钮 disabled=本族底 α0.45 幽灵化(甲案)" | is_equal_approx(d_mod.a, 0.45) and d_mod.r == 1.0 and d_mod.g == 1.0 | 普通钮禁用=本族底 α0.45 幽灵化(甲案)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 913 | 6515 | "V1 普通钮 disabled≠pressed(旧错配已修正)" |_sb_content_differs(d_sb, p_sb)| 普通钮禁用≠按压(旧错配已修正)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 914 | 6517 | "V1 普通钮 focus 样式盒为扁平环(StyleBoxFlat)" | f_sb is StyleBoxFlat | 普通钮焦点样式盒为扁平环。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 915 | 6519 | "V1 焦点环色=INK(与 font_focus_color 同源)" | fsb.border_color == uikit_s.INK | 焦点环色=INK(与焦点字色同源)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 916 | 6520 | "V1 焦点环宽=2px" | is_equal_approx(fsb.border_width_left, 2.0) and is_equal_approx(fsb.border_width_right, 2.0) and is_equal_approx(fsb.border_width_top, 2.0) and is_equal_approx(fsb.border_width_bottom, 2.0) | 焦点环宽=2px(四边)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 917 | 6523 | "V1 焦点环内缩=1px" | is_equal_approx(fsb.expand_margin_left, -1.0) and is_equal_approx(fsb.expand_margin_top, -1.0) and is_equal_approx(fsb.expand_margin_right, -1.0) and is_equal_approx(fsb.expand_margin_bottom, -1.0) | 焦点环内缩=1px(四边)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 918 | 6526 | "V1 焦点环无底(叠加态不遮钮底)" | not fsb.draw_center and fsb.bg_color.a < 0.001 | 焦点环无底(叠加态不遮钮底)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 919 | 6531 | "V1 主钮 %s 样式盒非空" ↩ % st | pb.has_theme_stylebox_override(st) | 主钮五态样式盒逐态非空。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 920 | 6535 | "V1 主钮 pressed/disabled=本族平涂(非石纹)" | ppn is StyleBoxFlat and pdd is StyleBoxFlat | 主钮按压/禁用=本族平涂(非石纹)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 921 | 6536 | "V1 主钮 pressed=烛光黄深档(现行值不动)" | (ppn as StyleBoxFlat).bg_color == Color8(214, 172, 88) | 主钮按压=烛光黄深档(现行值)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 922 | 6537 | "V1 主钮 disabled=本族底 α0.45 幽灵化" | is_equal_approx((pdd as StyleBoxFlat).bg_color.a, 0.45) | 主钮禁用=本族底 α0.45 幽灵化。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 923 | 6538 | "V1 主钮 disabled≠pressed" |_sb_content_differs(ppn, pdd)| 主钮禁用≠按压。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 924 | 6539 | "V1 主钮 hover=提亮档(承现行不动)" | (phv as StyleBoxFlat).bg_color == uikit_s.CANDLE.lightened(0.06) | 主钮悬停=提亮档(承现行)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 925 | 6544 | "V1 危险钮 %s 样式盒非空" ↩ % st | db.has_theme_stylebox_override(st) | 危险钮五态样式盒逐态非空。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 926 | 6547 | "V1 危险钮 pressed/disabled=本族平涂(非石纹,旧错配已修正)" | dpr is StyleBoxFlat and dds is StyleBoxFlat | 危险钮按压/禁用=本族平涂。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 927 | 6548 | "V1 危险钮 pressed=深红深档(暗于常态)" | (dpr as StyleBoxFlat).bg_color.r < 180.0 / 255.0 | 危险钮按压=深红深档(暗于常态)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 928 | 6549 | "V1 危险钮 disabled=本族底 α0.45 幽灵化" | is_equal_approx((dds as StyleBoxFlat).bg_color.a, 0.45) | 危险钮禁用=α0.45 幽灵化。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 929 | 6550 | "V1 危险钮 disabled≠pressed" |_sb_content_differs(dpr, dds)| 危险钮禁用≠按压。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 930 | 6555 | "V2 红线维持 Button 类型词条零接触" | not th.get_type_list().has("Button") | 主题红线维持:Button 类型词条零接触。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 931 | 6562 | "V2 红线维持 focus 族词条零定义%s" ↩ % ("" if focus_hits == "" else "(违例:" + focus_hits + ")") | focus_hits == "" | 主题红线维持:focus 族词条零定义。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S72 _smoke_texture_filter(行 6569–6606 · 9 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 932 | 6569 | "V3 工程设置 default_texture_filter=0(Nearest,翻转面=立绘/瓦片/九宫格 crisp)" | v != null and int(v) == 0 | 工程默认纹理过滤=Nearest(像素 crisp)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 933 | 6572 | "V3 白名单在位:暗角 vignette 显式 LINEAR" | vg.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR | 白名单:暗角显式 LINEAR。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 934 | 6575 | "V3 白名单在位:光柱 beams 显式 LINEAR" | bm.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR | 白名单:光柱显式 LINEAR。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 935 | 6592 | "T4 光柱带心峰值 α≈0.05(8bit 截断量化下限 12/255,实测 %.5f)" ↩ % max_a | max_a >= 12.0 / 255.0 and max_a <= 0.05 | 光柱带心峰值 α≈0.05(量化下限内)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 936 | 6594 | "T4 光柱带内连续 alpha 过渡像素 %d 个(旧零过渡硬边已修)" ↩ % mid_a | mid_a > 100 | 光柱带内连续过渡像素>100(旧零过渡硬边已修)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 937 | 6595 | "T4 光柱纵向衰减生效(上半亮度 %.0f > 下半 %.0f 的 2 倍——上亮下暗)" ↩ % [top_sum, bot_sum] | top_sum > bot_sum * 2.0 | 光柱纵向衰减生效(上亮下暗 2 倍)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 938 | 6598 | "V3 白名单在位:地图渐变 gradient_base 显式 LINEAR" | gb.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR | 白名单:地图渐变显式 LINEAR。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 939 | 6602 | "V3 脚影显式 LINEAR 不丢(board_world.gd)" | bw_src.contains("shadow.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR") | 脚影显式 LINEAR 不丢(源码锚定)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 940 | 6605 | "V3 状态图标显式 LINEAR 不丢(status_slot_row.gd)" | ss_src.contains("icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR") | 状态图标显式 LINEAR 不丢(源码锚定)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S73 _smoke_contrast_inks(行 6615–6631 · 9 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 941 | 6615 | "V4 顺位徽标/重甲徽标 GOLD_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" ↩ % r_order | r_order >= 4.5 | 顺位徽标亮墨对舞台底对比≥4.5:1。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 942 | 6617 | "V4 魔法护甲徽标 HERO_BLUE_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" ↩ % r_ward | r_ward >= 4.5 | 魔法护甲徽标亮墨对比≥4.5:1。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 943 | 6619 | "V4 RAP 反馈行 STONE_DEEP 对数据板 %.2f:1 ≥4.5:1" ↩ % r_rap | r_rap >= 4.5 | RAP 反馈行深墨对数据板对比≥4.5:1。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 944 | 6622 | "V4 顺位徽标①②③ 墨=GOLD_BRIGHT(13px 行原位)" | sb_src.contains("HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_BRIGHT") | 顺位徽标墨=亮金(源码锚定原位)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 945 | 6624 | "V4 魔法护甲徽标墨=HERO_BLUE_BRIGHT" | sb_src.contains("col = UiKit.HERO_BLUE_BRIGHT") | 魔法护甲徽标墨=亮蓝。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 946 | 6625 | "V4 重甲徽标墨=GOLD_BRIGHT" | sb_src.contains("col = UiKit.GOLD_BRIGHT") | 重甲徽标墨=亮金。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 947 | 6626 | "V4 GOLD_DEEP 语义岗不动(standee:199 意图动画判据)" | sb_src.contains("if _intent_col == UiKit.GOLD_DEEP:") | 深金语义岗不动(意图动画判据)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 948 | 6629 | "V4 RAP 反馈行墨=STONE_DEEP(原位行锚定)" | ap_src.contains('UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)') | RAP 反馈行墨=深石墨(原位行锚定)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 949 | 6631 | "V4 RAP 反馈行旧墨 STONE_FAINT 清零(含注记面)" | not ap_src.contains("STONE_FAINT") | RAP 反馈行旧墨零残留(含注记面)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S74 _smoke_element_details(行 6640–6658 · 8 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 950 | 6640 | "T5② BCard 图标槽白图标对比 %.2f:1 ≥3:1(硬约束)" ↩ % r_white | r_white >= 3.0 | BCard 图标槽白图标对比≥3:1(图形硬约束)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 951 | 6643 | "T5② 明度差降档生效(槽对卡面 %.2f:1 < 旧 %.2f:1)" ↩ % [r_new, r_old] | r_new < r_old | 图标槽明度差降档生效(新槽<旧槽对比)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 952 | 6645 | "T5② BCard 图标槽落柔和档(SLOT_BACKDROP_SOFT)" | uk_src.contains("UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)") | 图标槽落柔和档(源码锚定)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 953 | 6648 | "T5② B 区状态图标槽仍用深档(SLOT_BACKDROP,未误伤)" | ss_src.contains("UiKit.SLOT_BACKDROP") | B 区状态图标槽仍用深档(未误伤)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 954 | 6651 | "T5③ 商店卡片区垂直居中(ALIGNMENT_CENTER)" | shop_src.contains("_cards_box.alignment = BoxContainer.ALIGNMENT_CENTER") | 商店卡片区垂直居中(源码锚定)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 955 | 6655 | "T5③ 营地三 holder 统一 min 高 336(实测 %d 处)" ↩ % camp_min | camp_min == 3 | 营地三 holder 统一最小高 336。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 956 | 6657 | "T5③ 营地四栏面板纵向 FILL 等高(实测 %d 处)" ↩ % camp_fill | camp_fill == 4 | 营地四栏面板纵向等高填充。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 957 | 6658 | "T5③ 三处锁文垂直居中在位" | camp_src.count("SIZE_SHRINK_CENTER") >= 3 | 三处锁文垂直居中在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S75 _smoke_diag_button_retired(行 6665–6674 · 7 条 · 去向:单元测试 → tests/unit/presentation/theme_projection_test.gd · 施工组 G11) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 958 | 6665 | "V6 主区无 build_diag_button 调用残留(旧实现同批清理)" | not main_src.contains("build_diag_button") | 主区无旧诊断钮工厂调用残留。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 959 | 6666 | "V6 转储入口连接随迁(组合根接浮窗 diagnose_requested 信号)" | main_src.contains("debug_overlay.diagnose_requested.connect(_on_diagnose_pressed)") | 转储入口连接随迁(组合根接浮窗信号)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 960 | 6668 | "V6 转储编排承接函数在位(dump_diagnosis 卡死取证保留)" | main_src.contains("func _on_diagnose_pressed()") | 转储编排承接函数在位(卡死取证保留)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 961 | 6670 | "V6 build_diag_button 工厂已退役(ui_assembler 零残留)" | not asm_src.contains("build_diag_button") | 旧诊断钮工厂已退役(装配器零残留)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 962 | 6672 | "V6 调试浮窗 diagnose_requested 信号在位" | dbg_src.contains("signal diagnose_requested") | 调试浮窗转储信号在位。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 963 | 6673 | "V6 浮窗转储钮外发方法在位(纯视图边界)" | dbg_src.contains("_emit_diagnose_requested") | 浮窗转储钮外发方法在位(纯视图边界)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | +| 964 | 6674 | "V6 浮窗诊断转储钮在位(玩家画面角落钮清零后唯一入口)" | dbg_src.contains('UiKit.button("转储")') | 浮窗诊断转储钮在位(唯一入口)。 | 迁单元测试(tests/unit/presentation/theme_projection_test.gd) | | + +## S76 _smoke_save(行 6713–6977 · 70 条 · 去向:单元测试 → tests/unit/core/save_manager_domain_test.gd · 施工组 G10;去向补正:转办行→cross_system_flow_test.gd,见维护记录) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 965 | 6713 | "apply 原语入账(钥石 7+3、通关次数 1/1/0)" | w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0 | 收束原语入账(钥石+通关次数按摘要)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 966 | 6715 | "首杀标记随通关关卡置位(1/2 有、3 无)" | w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3) | 首杀标记随通关关卡置位。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 967 | 6717 | "生涯终身计数随 apply 自增恰一(中止局:总局数 1、通关局数 0——迭代 18 卡④)" | w.adventure_total() == 1 and w.adventure_cleared() == 0 | 生涯终身计数随收束自增恰一(中止局口径)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 968 | 6719 | "写盘成功" | w.write_to_disk() | 写盘成功。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 969 | 6721 | "读回成功" | r.load_from_disk() | 读回成功。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 970 | 6722 | "钥石余额往返相等(10)" | r.keystone_balance() == 10 | 钥石余额往返相等。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 971 | 6723 | "树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)" | r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b" | 树域往返相等(节点/分叉侧)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 972 | 6725 | "锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕" | r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0 and r.forge_unlocked_purchases() == ["spear"] and r.forge_weapon() == "spear" and r.forge_armor() == "ward" | 锻造域往返相等(等级/直购/选中)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 973 | 6729 | "道具解锁往返相等" | r.item_unlocked("probe_compass") and not r.item_unlocked("rope") | 道具解锁往返相等。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 974 | 6730 | "通关次数表往返相等" | r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0 | 通关次数表往返相等。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 975 | 6731 | "首杀标记往返相等" | r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3) | 首杀标记往返相等。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 976 | 6733 | "生涯终身计数往返相等(1/0)" | r.adventure_total() == 1 and r.adventure_cleared() == 0 | 生涯终身计数往返相等。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 977 | 6735 | "冒险摘要往返相等(1 条,seed 99)" | r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3 | 冒险摘要往返相等(1 条种子 99)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 978 | 6740 | "新档合法态(load true + 全零默认)" | f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0 and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty() | 新档合法态(读入 true+全零默认)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 979 | 6743 | "新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)" | f2.adventure_total() == 0 and f2.adventure_cleared() == 0 | 新档生涯计数缺省全零(白板完整可玩)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 980 | 6749 | "余额 5 花 8 拒绝且余额不动" | not f3.spend_keystone(8) and f3.keystone_balance() == 5 | 余额不足花钥石拒绝且余额不动。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 981 | 6750 | "余额内消费成功(5→2)" | f3.spend_keystone(3) and f3.keystone_balance() == 2 | 余额内消费成功。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 982 | 6758 | "高版本拒读(load false)" | not f5.load_from_disk() | 高版本档拒读(读入 false)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 983 | 6759 | "拒读后原文件字节不动" | FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text | 拒读后原文件字节不动。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 984 | 6760 | "拒读态写盘拒绝且文件仍字节不动" | not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text | 拒读态写盘拒绝且文件仍字节不动。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 985 | 6769 | "损坏档兜底(默认数据继续)" | f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty() | 损坏档兜底(默认数据继续)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 986 | 6771 | "坏档备份 .corrupt.bak 存在" | FileAccess.file_exists(paths[4] + ".corrupt.bak") | 坏档备份文件存在。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 987 | 6772 | "备份内容 = 原坏档字节" | FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk | 备份内容=原坏档字节。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 988 | 6782 | "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)" | f7.load_from_disk() and f7.keystone_balance() == 12 | v0 旧档经迁移链入账。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 989 | 6783 | "迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)" | f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7 | 迁移链写盘版本=7(链式升到现行)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 990 | 6795 | "域界干净——meta 域无局内资源键(发现 %s)" ↩ % str(leaked) | leaked.is_empty() | 域界干净——meta 域无局内资源键。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 991 | 6803 | "摘要 cap 10(12 份 → 10 条)" | arr.size() == 10 | 摘要上限 10(12 份截断)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 992 | 6804 | "最新在前(头 seed 11 / 尾 seed 2)" | int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2 | 摘要最新在前。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 993 | 6815 | "摘要集成:冒险驱动满配通关(seed 42)" | String(st9.get("result", "")) == "completed" | 摘要集成:真实局满配通关。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 994 | 6816 | "集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" ↩ % f9.keystone_balance() | f9.keystone_balance() == ks_run | 集成钥石余额=ΣBoss 首杀行值。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 995 | 6817 | "集成通关次数表全 1" | f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1 | 集成通关次数表全 1。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 996 | 6818 | "集成三关首杀标记齐" | f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3) | 集成三关首杀标记齐。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 997 | 6820 | "集成 recent 1 条且 seed 42" | f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42 | 集成摘要 1 条且种子正确。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 998 | 6824 | "第二局后通关次数全 2" | f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2 | 第二局后通关次数全 2。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 999 | 6825 | "第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)" | f9.keystone_balance() == 2 * ks_run | 第二局后余额翻倍。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 1000 | 6826 | "集成 recent 长度 2" | f9.recent_summaries().size() == 2 | 集成摘要长度 2。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 1001 | 6827 | "集成写盘成功" | f9.write_to_disk() | 集成写盘成功。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 1002 | 6829 | "集成写盘读回全量保持" | r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2 and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3) | 集成写盘读回全量保持。 |迁集成测试(tests/integration/cross_system_flow_test.gd)| | +| 1003 | 6835 | "断点:新档无断点(合法空态——「继续上局」隐藏)" | not fbp.has_breakpoint() | 断点:新档无断点(合法空态)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1004 | 6846 | "断点:写入+写盘成功" | fbp.has_breakpoint() and fbp.write_to_disk() | 断点写入+写盘成功。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1005 | 6848 | "断点:读回在档" | rb.load_from_disk() and rb.has_breakpoint() | 断点读回在档。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1006 | 6850 | "断点七字段往返:关卡序号/节点位/生命" | int(bp.get("level_index", -1)) == 2 and int(bp.get("node_id", -1)) == 5 and int(bp.get("hero_hp", -1)) == 13 | 断点往返:关卡序号/节点位/生命。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1007 | 6853 | "断点七字段往返:属性加成区" | int(bp_bonus.get("atk", -1)) == 2 and int(bp_bonus.get("max_hp", -1)) == 5 and int(bp_bonus.get("armor_class", -1)) == 1 | 断点往返:属性加成区。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1008 | 6855 | "断点七字段往返:GP 钱包" | int(bp.get("gp", -1)) == 17 | 断点往返:GP 钱包。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1009 | 6857 | "断点七字段往返:商店已购档" | int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1 | 断点往返:商店已购档。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1010 | 6859 | "断点七字段往返:已抽模板" | int(bp.get("template_pick", -1)) == 3 | 断点往返:已抽模板。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1011 | 6860 | "断点附随件往返:主种子/计数器/钥石累计" | int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38 and int(bp.get("keystone", -1)) == 6 | 断点附随件往返:主种子/计数器/钥石累计。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1012 | 6863 | "断点附随件往返:已到节点序" | bp_visited.size() == 3 and int(bp_visited[0]) == 0 and int(bp_visited[2]) == 5 | 断点附随件往返:已到节点序。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1013 | 6866 | "断点附随件往返:道具库存" | bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout" | 断点附随件往返:道具库存。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1014 | 6869 | "断点读面值拷贝(改读面不渗档)" | int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13 | 断点读面值拷贝(改读面不渗档)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1015 | 6872 | "断点:清除+写盘(三结局收束口径)" | not rb.has_breakpoint() and rb.write_to_disk() | 断点清除+写盘。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1016 | 6874 | "断点:清除后读回无断点" | rc.load_from_disk() and not rc.has_breakpoint() | 断点清除后读回无断点。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1017 | 6879 | "v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)" | fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint() | v1 旧档迁移:断点域补空(合法空态不炸)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1018 | 6881 | "v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)" | fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7 | v1 旧档迁移后写盘版本=7(链式)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1019 | 6893 | "v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)" | fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7 | v2 旧档迁移:断点域与 meta 域原样保留。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1020 | 6895 | "v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)" | int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777 | v2 旧断点字段零丢失(向后兼容)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1021 | 6898 | "v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)" | adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}) | v2 旧断点续玩受理(缺附随件走常规重建)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1022 | 6907 | "v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)" | f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 and f13.keystone_balance() == 9 | v5 旧档升版:计数域缺省补零且既有余额原样(只增不改)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1023 | 6910 | "升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)" | f13.write_to_disk() and f13.load_from_disk() and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7 and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 | 升档幂等:写盘再载零漂移。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1024 | 6918 | "收束自增恰一·通关局(三关全通 cleared=true:总局数+1 且通关局数+1)" | f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1 | 收束自增恰一·通关局(总局数+1 且通关局数+1)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1025 | 6922 | "收束自增恰一·中止局(放弃局:仅总局数+1,通关局数不动)" | f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1 | 收束自增恰一·中止局(仅总局数+1)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1026 | 6933 | "换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动" | f15.load_from_disk() and f15.keystone_balance() == 33 and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty() | 换代前结构存档:树域废弃重置(旧残留清退、余额零折算)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1027 | 6938 | "废弃重置后后续购买正常受理(无残留脏节点)" | mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1 | 废弃重置后后续购买正常受理(无残留脏节点)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1028 | 6946 | "v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕" | f16.load_from_disk() and f16.keystone_balance() == 21 and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1 | v6 旧档升版:既有域逐位不动(迁移只增不改)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1029 | 6949 | "v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕" | f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 and f16.forge_unlocked_purchases().is_empty() and f16.forge_weapon() == "sword" and f16.forge_armor() == "light" | v6 旧档缺键补齐=初始持有态(六路线 0 级/选中默认)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1030 | 6956 | "forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕" | fr_keys == ["armor", "levels", "unlocked", "weapon"] | 锻造域键集恰四项(无特效域)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1031 | 6960 | "levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕" | lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"] | levels 六路线键集恰全。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1032 | 6965 | "forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕" | f16.forge_route_level("sword") == 0 | 锻造快照值拷贝出户(改快照不渗档)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1033 | 6972 | "收束链自增·中止局(settle_adventure 真实挂点:总局数+1、通关局数不动)" | f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1 | 收束链自增·中止局(真实挂点全链恰一)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1034 | 6976 | "收束链自增·通关局(三关全通:总局数+1 且通关局数+1——全链恰一)" | f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2 | 收束链自增·通关局(全链恰一)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | + +## S77 _smoke_save_slots(行 7007–7146 · 31 条 · 去向:单元测试 → tests/unit/core/save_manager_domain_test.gd · 施工组 G10) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1035 | 7007 | "sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" ↩ % i | t.activate_slot(i) | 进档受理(激活+读盘)逐槽。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1036 | 7010 | "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" ↩ % i | t.write_to_disk() | 激活态写盘受理逐槽。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1037 | 7014 | "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" ↩ % i | t2.activate_slot(i) and t2.keystone_balance() == 100 * i and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0) | 三槽往返一致(异值按位读回)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1038 | 7020 | "sv3 写盘受理·槽 1 加值后〔卡① sv1〕" | t.write_to_disk() | 槽 1 加值后写盘受理。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1039 | 7021 | "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕" | FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before | 位间隔离:改槽 1 槽 2 字节不动。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1040 | 7025 | "sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕" | t.activate_slot(3) | 切激活至槽 3(删档夹具前置)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1041 | 7026 | "sv6 删档受理·激活槽 3〔卡① sv6〕" | t.delete_slot(3) | 删激活槽受理。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1042 | 7027 | "sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕" | not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp") | 删后文件不在盘+无临时残留。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1043 | 7029 | "sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕" | t.active_slot() == 0 and not t.has_write_target() | 删激活槽激活指针连清除(该槽不再可写)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1044 | 7033 | "sv6 删非激活槽受理·槽 2〔卡① sv6〕" | t.delete_slot(2) | 删非激活槽受理。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1045 | 7034 | "sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕" | not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1 | 删非激活槽:文件不在盘+激活指针保持。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1046 | 7036 | "sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕" | FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before | 删槽 2 槽 1 字节不动(位间互隔离)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1047 | 7045 | "sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕" | t.create_slot(3) | 新档受理(创建即激活)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1048 | 7046 | "sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕" | t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() and t.adventure_total() == 0 | 新档=全零可玩合法白板态(旧内容零带入)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1049 | 7050 | "sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕" | int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0 | 新档落盘=白板内容(盘面核验非旧值)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1050 | 7059 | "sv5 进档槽 1 读回(107)〔卡① sv10〕" | hv.activate_slot(1) and hv.keystone_balance() == 107 | 进档槽 1 读回(高版本夹具前置)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1051 | 7061 | "sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕" | not hv.activate_slot(2) | 高版本槽进档拒绝(拒读禁写链按槽)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1052 | 7062 | "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕" | hv.active_slot() == 1 and hv.keystone_balance() == 107 | 激活失败恢复切换前三态(路径/激活号/数据)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1053 | 7065 | "sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕" | hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv | 恢复后写盘落激活槽(高版本槽零误写)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1054 | 7076 | "sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" ↩ % i | mi.activate_slot(i) and mi.keystone_balance() == i * 11 and mi.write_to_disk() | 迁移链按槽成立(各槽独立+写盘版本=7)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1055 | 7091 | "sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" ↩ % i | bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i | 窥视三项逐槽正确(余额/通关/总局数)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1056 | 7094 | "sv6 删槽 3 备空槽窥视〔卡① sv6〕" | pv.delete_slot(3) | 删槽 3 备空槽窥视。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1057 | 7096 | "sv7 空槽窥视=零值合法态〔卡① sv7〕" | bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0 | 空槽窥视=零值合法态。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1058 | 7105 | "sv8 坏槽窥视=显式损坏标记〔卡① sv8〕" | not bool(pb.get("readable", true)) | 坏槽窥视=显式损坏标记。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1059 | 7106 | "sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕" | FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes and FileAccess.get_modified_time(slot_tpl % 1) == bad_mtime and not FileAccess.file_exists(slot_tpl % 1 + ".corrupt.bak") | 坏槽窥视零副作用(字节+mtime 双比对+零改名)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1060 | 7116 | "sv8 高版本槽窥视=不可读标记〔卡① sv8〕" | not bool(ph.get("readable", true)) | 高版本槽窥视=不可读标记。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1061 | 7117 | "sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕" | FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes and FileAccess.get_modified_time(slot_tpl % 2) == hv_mtime | 高版本窥视零副作用(字节+mtime 不变)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1062 | 7130 | "sv12 旧档字节迁入槽 1(逐域相等——余额 1234)〔卡① sv12〕" | int((migrated.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 | 旧档字节迁入槽 1(逐域相等)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1063 | 7132 | "sv12 迁移后源改名留痕(.migrated.bak 在盘、原路径不在)〔卡① sv12〕" | not FileAccess.file_exists(legacy_src) and FileAccess.file_exists(legacy_src + ".migrated.bak") | 迁移后源改名留痕(备份在盘原路径不在)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1064 | 7141 | "sv12 幂等:槽 1 已存在不重迁(预置 777 零覆盖、源保留)〔卡① sv12〕" | int((slot1_now.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 and FileAccess.file_exists(legacy_src) | 幂等:槽已存在不重迁(零覆盖源保留)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | +| 1065 | 7144 | "sv12 迁移他槽零触碰(槽 2/3 字节不动)〔卡① sv12〕" | FileAccess.get_file_as_bytes(slot_tpl % 2) == s2_before and FileAccess.get_file_as_bytes(slot_tpl % 3) == s3_before | 迁移他槽零触碰(槽 2/3 字节不动)。 | 迁单元测试(tests/unit/core/save_manager_domain_test.gd) | | + +--- +全源共 77 章、1065 条(静态调用点口径)。 diff --git a/reports/批B-断言迁移账本-2026-10-22.md b/reports/批B-断言迁移账本-2026-10-22.md new file mode 100644 index 00000000..18d979c1 --- /dev/null +++ b/reports/批B-断言迁移账本-2026-10-22.md @@ -0,0 +1,431 @@ +# 批 B 断言迁移账本(波 0 基建产出) + +> 状态:波 0 基建完成 · 2026-10-22 · 主刀代理(波 1 迁移)的施工输入合同 +> 基点 commit:`1aea93972b5ff3bc51de1814081e69a79f74a735`(smoke_all.gd 在此基点冻结) +> 合同:`reports/提案-测试术语与层级重构方案-2026-10-22.md` §四/§五/§七 +> 冻结参照物:`game/tests/验证集清单.md`(机器生成+逐条白话释义+处置列已回填;出口对账=`python tools/extract_checks.py --diff game/tests/验证集清单.md`) +> 波 0 纪律遵守:`game/smoke/smoke_all.gd` 零改动(git diff 空);三个去向既有测试文件零改动;未跑任何游戏测试 + +## 一、提取对账(先读这一节) + +- **静态调用点 = 1065 处**(`tools/extract_checks.py` 括号配平提取,含跨行调用归集)。 +- **与合同 §一「1067」的差**:1067 是行级 `grep -c "check("` 口径,多出的 2 行=`func check(` 定义行(:14)+一处注释行(:3452「禁恒真 check(true)」),均非调用点。清单头注已写明两口径,**迁移对账一律以 1065 为准**。 +- **--diff 自测**:对刚生成的清单跑 `--diff`=零差异(计数 1065/1065 对齐、标签/cond 逐字对齐);释义与处置列回填后复跑仍零差异(回填不触冻结列)。 +- 清单结构:77 章(套)×1065 条;每条含序号/基点行号/标签原文(逐字)/cond 摘要(跨行折叠单行)/白话释义/处置/quiet(全源仅 1 处 quiet=true,:3448)。 + +## 二、分类账总表(77 套) + +去向判据(tests/README「分层判据」):`--script` 不加载 autoload 能验完=单元;跨系统且走落盘=集成;需完整项目引导与场景树=系统测试。套=smoke_all.gd 的断言函数;行段=基点行号。 + +| 套 | 行段 | 条数 | 去向 | 新宿主 | 守的生产模块(变异改坏点落点) | 组 | +|---|---|---|---|---|---|---| +| _initialize | 44–70 | 14 | 单元 | unit/data_config/rng_stream_boot_test.gd | data/tables 加载链+core/rng_service.gd(统一流黄金向量/取号/事件日志) | G12 | +| _split_tail | 80–94 | 1 | 系统 | tests/system_test.gd | autoload 装配链就绪护栏(main.tscn 引导) | G1 | +| _smoke_type_gate | 99–105 | 2 | 单元 | unit/contracts/source_guard_test.gd | project.godot 警告配置+tools/audit_type_hints.py 闸接线 | G12 | +| _smoke_hygiene_gates | 109–153 | 6 | 单元 | unit/contracts/source_guard_test.gd | 产品码源码卫生(core/presentation/data 三目录死代码/影子账/层序单口) | G12 | +| _smoke_scene_split | 156–216 | 11 | 系统 | tests/system_test.gd | presentation 三屏壳+main.tscn 层序合同(LAYER_CONTRACT) | G1 | +| _smoke_boot_chain | 242–312 | 15 | 系统 | tests/system_test.gd | main.gd boot 链(设置应用/续玩钮/启动态/调试浮窗)+SaveManager 启动态 | G1 | +| _smoke_lifecycle_matrix | 319–492 | 10 | 系统 | tests/system_test.gd | main.gd 九阶段生命周期(引用面/模态开合/两连局/续玩断点) | G1 | +| _stage_faces | 527–579 | 1 | 系统 | tests/system_test.gd | 呈现层引用面快照协议(现值回调与 run_scope 同源) | G1 | +| _smoke_run_lifecycle | 592–645 | 11 | 系统 | tests/system_test.gd | main.gd 会话物化(脏态复位/断点清理/两连局零泄漏) | G1 | +| _smoke_node_map_templates | 716–784 | 14 | 集成 | integration/node_map_templates_test.gd | level_runner+adventure_runner+save_manager+meta_progression(模板池跨系统+首冒险置位落盘) | G12 | +| _smoke_adventure_table | 804–915 | 17 | 单元 | unit/data_config/table_structure_guard_test.gd | data/tables/adventure.tres+同调目录 12 件+orchestrator 装配贯通 | G2 | +| _smoke_adventure_api | 919–955 | 11 | 单元 | unit/core/adventure_feats_test.gd | core/adventure_runner.gd 状态机受理面(重复拒/停点序/放弃两态) | G7 | +| _smoke_adventure | 974–1287 | 54 | 单元 | unit/core/adventure_feats_test.gd | adventure_runner 冒险结构(三关/大奖励/账本封闭/败北线/恢复数学/共享池) | G7 | +| _smoke_feats | 1292–1637 | 71 | 单元 | unit/core/adventure_feats_test.gd | data/tables/feats.tres+core/effect_engine.gd 逐件订阅+凭证保底 | G7 | +| _smoke_layout_cell_formula | 1691–1705 | 2 | 单元 | unit/core/board_gen_reward_test.gd | presentation/views/board_view.gd 格边长公式(下缘预留回归) | G3 | +| _smoke_type_glow | 1767–1955 | 24 | 单元 | unit/presentation/theme_projection_test.gd | board_view 微光数据面(作用集恒等/九型归组/零玩法写入) | G11 | +| _smoke_reward_identity_unit | 1977–2019 | 10 | 单元 | unit/core/board_gen_reward_test.gd | core/reward_economy.gd 身份面(roll 恰一次/五型封闭/幂等) | G3 | +| _smoke_seal_ap_exhausted | 2027–2058 | 5 | 单元 | unit/battle_settlement/seal_polarity_test.gd | core/action_economy.gd 末点 AP 封印轮末结算(playtest 缺陷②回归) | G5 | +| _smoke_level | 2063–2094 | 11 | 单元 | unit/core/level_shop_economy_test.gd | level_runner 线性推进(经 level_autoplay:满配/败北/回放/恢复公式) | G8 | +| _smoke_enemy_bands | 2099–2143 | 4 | 单元 | unit/data_config/table_structure_guard_test.gd | data/tables/enemy.tres 带键结构(20 行键序/hobgoblin 留任/Boss 行) | G2 | +| _smoke_enemy_attack_type | 2151–2205 | 4 | 单元 | unit/battle_settlement/roll_multiplier_test.gd | 敌 attack_type 轴+克制矩阵消费(±1 位移证词) | G4 | +| _smoke_ac_swap_guard | 2215–2320 | 12 | 单元 | unit/data_config/table_structure_guard_test.gd | status_effect/reward/settlement 表(AC 单轨/免伤墙护栏/掷点参数/敌 AC 带宽) | G2 | +| _smoke_armor_roll | 2325–2400 | 15 | 单元 | unit/battle_settlement/roll_multiplier_test.gd | core/damage_math.gd 查档+orchestrator 掷点行为(nat20/下限/续时) | G4 | +| _smoke_pool_wiring | 2474–2487 | 4 | 单元 | unit/battle_settlement/roll_multiplier_test.gd | orchestrator 乘区接线(×1.5/×0.75/硬化折半) | G4 | +| _smoke_board_generator | 2509–2564 | 22 | 单元 | unit/core/board_gen_reward_test.gd | core/board_generator.gd(可清性/合法性/出图验收/确定性/r0 闸) | G3 | +| _smoke_variation_framework | 2576–2603 | 9 | 单元 | unit/data_config/table_structure_guard_test.gd | data/tables/variation.tres+config_db 池门控+跨字段互锁 | G2 | +| _smoke_global_variant | 2634–2749 | 30 | 单元 | unit/battle_settlement/variant_placement_test.gd | orchestrator 全局变异兜底回落+reward_economy 偏斜投影 | G6 | +| _smoke_variant_placement | 2795–2873 | 9 | 单元 | unit/battle_settlement/variant_placement_test.gd | orchestrator 战斗初始化布置段(门控/布置域/数量公式/连通片) | G6 | +| _smoke_full_battle | 2896–2928 | 7 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | orchestrator 全战斗回放(双流逐位/判定行/先手必兑) | G6 | +| _run_battle | 2931–2962 | 1 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | orchestrator 收束相位回 Idle(随 S29 迁) | G6 | +| _smoke_power_interval | 2988–3055 | 5 | 单元 | unit/core/board_gen_reward_test.gd | reward_economy 区间行(中点锚/流动/定值不受扰) | G3 | +| _smoke_active_refresh | 3069–3144 | 17 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | action_economy 轮内刷新(上限/双账/V5 快照单源/回放) | G6 | +| _smoke_difficulty_table | 3171–3193 | 8 | 单元 | unit/data_config/table_structure_guard_test.gd | data/tables/difficulty.tres+unit_container 修饰(空表/套乘/clamp) | G2 | +| _smoke_damage_types | 3198–3229 | 10 | 单元 | unit/battle_settlement/roll_multiplier_test.gd | unit_container 护甲类型+克制矩阵表值+标错/标中兑付 | G4 | +| _smoke_seal_irreversible_quadrants | 3286–3388 | 21 | 单元 | unit/battle_settlement/seal_polarity_test.gd | minesweeper_rules 封印不可逆四情况+拒绝面 | G5 | +| _smoke_playtest_logger | 3392–3436 | 8 | 单元 | unit/presentation/theme_projection_test.gd | presentation/playtest_logger.gd(快照 JSONL/诊断转储) | G11 | +| _smoke_encounter_roll | 3441–3506 | 8 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | orchestrator 遭遇区间骰(域内/回放/首遇带固定/空遭遇不可达) | G6 | +| _smoke_attack_chain_fresh_pool | 3566–3590 | 3 | 单元 | unit/battle_settlement/roll_multiplier_test.gd | damage_controller fresh 池攻击链(Bug#2 掷点口径) | G4 | +| _smoke_clear_reward_chain | 3611–3631 | 5 | 单元 | unit/battle_settlement/seal_polarity_test.gd | orchestrator 清图奖励双份兑付(真实流完美封印) | G5 | +| _smoke_clear_reward_zero_paths | 3636–3690 | 11 | 单元 | unit/battle_settlement/seal_polarity_test.gd | 清图奖励弱形式分支(零兑付/高光守卫/第二份拒) | G5 | +| _smoke_refresh_provenance_guard | 3694–3709 | 5 | 单元 | unit/battle_settlement/seal_polarity_test.gd | orchestrator 刷新图 provenance 守卫 | G5 | +| _smoke_polarity_routing | 3740–3853 | 12 | 单元 | unit/battle_settlement/seal_polarity_test.gd | orchestrator 极性路由+封印溢价 125% | G5 | +| _smoke_negative_dictionary | 3912–3998 | 8 | 单元 | unit/battle_settlement/seal_polarity_test.gd | 负向字典逐型兑付形态(效果行/直伤/警觉/行动点排队) | G5 | +| _smoke_resource_surface | 4003–4044 | 5 | 单元 | unit/battle_settlement/seal_polarity_test.gd | 揭示事件资源标记+封锁格身份查询面 | G5 | +| _smoke_settlement_segments | 4048–4115 | 5 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | settlement 五段全序+行型封闭+案C 段内序 | G6 | +| _smoke_skill_pool_data | 4232–4406 | 27 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | data/tables/skill.tres+enemy.tres 池模板+解锁技编排集成 | G6 | +| _smoke_skill_pool_replay_independence | 4412–4426 | 2 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | SkillLottery 同种子回放 | G6 | +| _smoke_skill_pool_ev_anchor | 4452–4500 | 1 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | SkillLottery EV 带宽锚(含保底闸速率) | G6 | +| _smoke_skill_pool_death_void | 4504–4538 | 5 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | 编排死亡打断作废(不动手不进账) | G6 | +| _smoke_skill_pool_validation_reject | 4542–4587 | 13 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | skill/enemy 表加载校验拒绝族 | G6 | +| _smoke_debuff_rider | 4600–4662 | 3 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | 妨害技落点+毒爆必中附加逐拍复算 | G6 | +| _smoke_level_graph | 4667–4943 | 54 | 单元 | unit/core/level_shop_economy_test.gd | level.tres 图校验+level_runner 推进+enemy 行集定点+类型可见性 | G8 | +| _smoke_gp_wallet | 4960–5009 | 9 | 单元 | unit/core/level_shop_economy_test.gd | level_runner 钱包到账(掷定窗口/关级加值) | G8 | +| _smoke_gp_escrow | 5023–5108 | 12 | 单元 | unit/core/level_shop_economy_test.gd | orchestrator escrow 缓冲+钱包钳制不透支 | G8 | +| _smoke_shop_room | 5133–5244 | 22 | 单元 | unit/core/level_shop_economy_test.gd | level_runner 商店三收口(现价/加算区/拒购/离开/清零) | G8 | +| _smoke_shop_graph | 5256–5292 | 6 | 单元 | unit/core/level_shop_economy_test.gd | level.tres 商店房图不变式(必经位/跨表行集迁移) | G8 | +| _smoke_shop_pool | 5297–5450 | 27 | 单元 | unit/core/level_shop_economy_test.gd | adventure_runner 商店共享池(抽 3 买 1/乘法价/共库/断点扩键) | G8 | +| _smoke_nine_types_gp_stream | 5484–5524 | 6 | 单元 | unit/data_config/table_structure_guard_test.gd | reward.tres 十型封闭+18 行字典+gp 流权重 | G2 | +| _smoke_meta | 5529–5545 | 4 | 单元 | unit/data_config/table_structure_guard_test.gd | data/tables/meta.tres 结构(行族/词表/first≥repeat) | G2 | +| _smoke_forge_axes | 5556–5707 | 33 | 单元 | unit/core/meta_progression_forge_test.gd | meta_progression 锻造域+main.gd build_adventure_params+unit_container 两轴 | G9 | +| _status_panel_projection | 5713–5765 | 4 | 单元 | unit/presentation/theme_projection_test.gd | presentation/views/status_panel.gd 只读投影 | G11 | +| _career_overlay_projection | 5772–5833 | 8 | 单元 | unit/presentation/theme_projection_test.gd | presentation/views/career_overlay.gd 投影+v5 旧档显示 | G11 | +| _meta_pricing_and_buy | 5862–5883 | 4 | 单元 | unit/core/meta_progression_forge_test.gd | meta_progression 树购买(受理/持久/拒绝零副作用) | G9 | +| _meta_injection | 5887–5924 | 10 | 单元 | unit/core/meta_progression_forge_test.gd | meta_progression hero_override+adventure_runner 注入/白板 | G9 | +| _meta_settle_and_workshop | 5928–6110 | 42 | 单元 | unit/core/meta_progression_forge_test.gd | meta_progression 收束入账/工坊解锁/同调位/开局传参面 | G9 | +| _smoke_tree | 6115–6188 | 25 | 单元 | unit/core/meta_progression_forge_test.gd | meta_tree 表+购买面/Fork 双态/彩蛋觉醒 | G9 | +| _smoke_counter_buckets | 6193–6241 | 6 | 单元 | unit/battle_settlement/round_flow_skill_pool_test.gd | effect_engine 四桶+run_ledger 跨战持久 | G6 | +| _smoke_tree_rules | 6244–6347 | 19 | 单元 | unit/core/meta_progression_forge_test.gd | effect_engine 树六键+level_runner 计价+编排守侧 queue | G9 | +| _smoke_battle_log_view | 6354–6389 | 6 | 单元 | unit/presentation/theme_projection_test.gd | presentation/views/battle_log_view.gd 行协议同步闸 | G11 | +| _smoke_global_theme | 6395–6488 | 22 | 单元 | unit/presentation/theme_projection_test.gd | main_theme.tres+ThemeDB 挂载(词条/滑轨对比度/字体防御) | G11 | +| _smoke_button_five_states | 6492–6562 | 23 | 单元 | unit/presentation/theme_projection_test.gd | ui_kit 按钮四工厂五态矩阵 | G11 | +| _smoke_texture_filter | 6566–6606 | 9 | 单元 | unit/presentation/theme_projection_test.gd | project.godot 纹理过滤+ui_kit 白名单+光柱结构 | G11 | +| _smoke_contrast_inks | 6610–6631 | 9 | 单元 | unit/presentation/theme_projection_test.gd | ui_kit 墨色对比度+standee/ap_bar 源码锚 | G11 | +| _smoke_element_details | 6635–6658 | 8 | 单元 | unit/presentation/theme_projection_test.gd | ui_kit 槽色降档+商店/营地布局源码锚 | G11 | +| _smoke_diag_button_retired | 6662–6674 | 7 | 单元 | unit/presentation/theme_projection_test.gd | main/ui_assembler/debug_overlay 诊断钮退役源码契约 | G11 | +| _smoke_save | 6680–6982 | 70 | 单元 | unit/core/save_manager_domain_test.gd | core/save_manager.gd 读写/迁移链/兜底/域界/摘要/断点域 | G10 | +| _smoke_save_slots | 6988–7151 | 31 | 单元 | unit/core/save_manager_domain_test.gd | save_manager 三槽位(往返/删档/窥视/迁移/旧单档) | G10 | + +计数核对:套 6+1+70=77;条 49(系统)+14(集成)+1002(单元:data_config 74+contracts 8+core 550+battle_settlement 242+presentation 128)=1065。 + +随套迁移的无断言 helper(63 个,与主套同 commit 剪切):`check/_theme_rel_luminance/_theme_contrast/_topmost_visible_layer/_face_snapshot/_face_liveness/_run_core_smoke/_smoke_rest_stub/_smoke_find_token/_api_mark_*/_adv_drive_to_shop/_adv_st/_glow_*/_ms_digest/_first_hero_attack_rows/_weaken_refresh_probe/_ac11_tier_samples/_hero_attack_row_with/_mk_stream/_errs_has_cross_field/_arr_str_eq/_rows_weights_equal/_rows_weight_map/_board_resource_dump/_variant_dump/_board_dump/_auto_play_round/_row_power/_active_refresh_replay/_run_seal_backlash_round/_find_seal_target/_row_kind_of/_boss_depth/_first_encounter_band/_level_active_nodes/_level_battle_nodes/_encounter_replay/_polarity_probe_accepts/_run_polarity_probe/_rows_at/_row_phase/_run_full_clear/_drive_clear/_rows_of_type/_has_source_row/_has_effect_id/_battle_st/_smoke_skill_pool(调度壳)/_record_pool_trajectories/_has_err_category/_fresh_rng/_escrow_sum_of/_find_gp_dig/_shop_products_by_id/_all_entry_boss_paths/_meta_smoke_cleanup/_meta_new_save`。归属:`check` 归各宿主自持(新宿主已有 Kit.check 或宿主 check,迁移时按各宿主协议换通道——**这是唯一的非逐字面**,见 §五注意事项 4);其余 helper 一对一随首用套迁移,共享 helper(`_fresh_rng/_find_seal_target/_auto_play_round/_run_battle` 等)落其首要消费者宿主、他人 preload。 + +## 三、施工组(12 组,供逐组原子提交) + +| 组 | 套 | 条数 | 宿主 | +|---|---|---|---| +| G1 系统·启动链尾段 | _split_tail、_smoke_scene_split、_smoke_boot_chain、_smoke_run_lifecycle、_smoke_lifecycle_matrix、_stage_faces(+_topmost_visible_layer/_face_snapshot/_face_liveness) | 49 | system_test.gd | +| G2 单元·表结构族 | _smoke_adventure_table、_smoke_enemy_bands、_smoke_ac_swap_guard、_smoke_variation_framework、_smoke_nine_types_gp_stream、_smoke_meta、_smoke_difficulty_table | 60 | unit/data_config/table_structure_guard_test.gd | +| G3 单元·棋盘生成与资源 | _smoke_board_generator、_smoke_layout_cell_formula、_smoke_reward_identity_unit、_smoke_power_interval | 39 | unit/core/board_gen_reward_test.gd | +| G4 单元·掷点与乘区 | _smoke_armor_roll、_smoke_pool_wiring、_smoke_enemy_attack_type、_smoke_attack_chain_fresh_pool、_smoke_damage_types | 36 | unit/battle_settlement/roll_multiplier_test.gd | +| G5 单元·封印与极性 | _smoke_seal_ap_exhausted、_smoke_seal_irreversible_quadrants、_smoke_polarity_routing、_smoke_negative_dictionary、_smoke_resource_surface、_smoke_clear_reward_chain、_smoke_clear_reward_zero_paths、_smoke_refresh_provenance_guard | 72 | unit/battle_settlement/seal_polarity_test.gd | +| G6 单元·轮内流与技能池+变异 | _smoke_active_refresh、_smoke_encounter_roll、_smoke_settlement_segments、_smoke_full_battle+_run_battle、_smoke_skill_pool_*(5 件)、_smoke_debuff_rider、_smoke_counter_buckets、_smoke_global_variant、_smoke_variant_placement | 134 | unit/battle_settlement/round_flow_skill_pool_test.gd+variant_placement_test.gd(双宿主) | +| G7 单元·冒险结构与专长 | _smoke_adventure_api、_smoke_adventure、_smoke_feats | 136 | unit/core/adventure_feats_test.gd | +| G8 单元·关卡图与 GP 商店 | _smoke_level、_smoke_level_graph、_smoke_gp_wallet、_smoke_gp_escrow、_smoke_shop_room、_smoke_shop_graph、_smoke_shop_pool | 141 | unit/core/level_shop_economy_test.gd | +| G9 单元·元进度与锻造 | _smoke_tree、_smoke_tree_rules、_meta_pricing_and_buy、_meta_injection、_meta_settle_and_workshop、_smoke_forge_axes | 133 | unit/core/meta_progression_forge_test.gd | +| G10 单元·存档域 | _smoke_save、_smoke_save_slots | 101 | unit/core/save_manager_domain_test.gd | +| G11 单元·呈现投影与主题 | _smoke_type_glow、_status_panel_projection、_career_overlay_projection、_smoke_battle_log_view、_smoke_global_theme、_smoke_button_five_states、_smoke_texture_filter、_smoke_contrast_inks、_smoke_element_details、_smoke_diag_button_retired、_smoke_playtest_logger | 128 | unit/presentation/theme_projection_test.gd | +| G12 单元·源码契约+集成 | _initialize、_smoke_type_gate、_smoke_hygiene_gates、_smoke_node_map_templates | 36 | unit/data_config/rng_stream_boot_test.gd+unit/contracts/source_guard_test.gd+integration/node_map_templates_test.gd | + +组内顺序建议:先小后大热身(G3/G4 → G2/G12 → G5/G6 → G1 → G8/G9 → G7 → G10 → G11);每组一个原子 commit(新宿主落函数+smoke_all.gd 同 commit 删函数)。 + +## 四、变异策略(到套级,按条执行细则照 test-conventions §6) + +标准改坏形态按生产模块族分四类,每套守的模块见 §二末列: + +1. **表结构类(G2/G12 表半/技能池数据面)**:改坏 `game/data/tables/*.tres` 一行值(如改 adventure.tres 档位配额、enemy.tres 一行 hp、meta.tres 首杀值)→ 跑该套断言验红 → `git checkout` 还原。 +2. **公式/纯逻辑类(G3/G4)**:改 core 公式一行(如 damage_math.gd 查档边界、board_generator.gd 尺寸公式、reward_economy.gd 中点取整)→ 验红还原。 +3. **状态机/编排行为类(G5/G6/G7/G8/G9 存档外半)**:改编排器/runner 分支(如删一处拒绝分支、翻转一个标志、改 action_economy 上限常量)→ 验红还原。 +4. **呈现/源码契约类(G11/G12 卫生面)**:改 ui_kit 常量或视图字段验红;源码契约面(hygiene/diag_button)的变异=往被扫产品码临时加一个违例符号(如一行 `Callable(self, "x")` 或 `build_diag_button` 调用)→ 验红还原。 +5. **系统类(G1)**:改 main.gd 装配序/层序合同一行或 settings 默认值 → 验红还原(跑 `run_system_test.py --script res://tests/system_test.gd`)。 + +注意:单元宿主断言通道是 `Kit.check("描述", cond)`(参序与 smoke 的 `check(cond, label)` 相反)——「只剪切禁改写」约束的是**标签字符串与 cond 表达式逐字**,调用通道换成宿主协议属宿主接线,不属改写;出口 `--diff` 比对的正是标签+cond 本身,不受通道影响。 + +## 五、宿主落位计划(本波不建任何宿主文件) + +**为什么本波不建**:空壳测试文件会触发单元运行器「断言数为零即红」(test_kit.gd `finish()` 零断言返回退出码 2),且 system_test.gd 现状零断言跑绿是批 A 有意的过渡态。宿主文件只随波 1 各组迁移 commit 出现。 + +**新宿主文件(11 个,随首组迁移创建)**: +- `game/tests/unit/data_config/rng_stream_boot_test.gd`(G12:_initialize 14 条) +- `game/tests/unit/data_config/table_structure_guard_test.gd`(G2) +- `game/tests/unit/contracts/source_guard_test.gd`(G12:type_gate+hygiene) +- `game/tests/integration/node_map_templates_test.gd`(G12:首个集成新增件) +- `game/tests/unit/core/board_gen_reward_test.gd`(G3) +- `game/tests/unit/battle_settlement/roll_multiplier_test.gd`(G4) +- `game/tests/unit/battle_settlement/seal_polarity_test.gd`(G5) +- `game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd`(G6 主宿主) +- `game/tests/unit/battle_settlement/variant_placement_test.gd`(G6 第二宿主) +- `game/tests/unit/core/adventure_feats_test.gd`(G7);`level_shop_economy_test.gd`(G8);`meta_progression_forge_test.gd`(G9);`save_manager_domain_test.gd`(G10);`unit/presentation/theme_projection_test.gd`(G11)——core 四件与 presentation 一件共 5 个。 + +**挂载方式**: +- **unit/integration**:新文件在 `game/tests/run_all.gd` `_suites()` manifest 追加 `{"path": "res://tests/unit/<域>/<名>_test.gd", "expect": 0}`(集成件同样进此 manifest,README 明示「与单元测试同一入口、同一协议」)。命名照 `[system]_[feature]_test.gd`;协议照 example_test.gd(extends SceneTree+Kit preload+quit(Kit.finish(...)));随组迁移的同批登记,禁出现「文件在 manifest 不在」的空窗(manifest 数即闸 02 计数口径)。 +- **system_test.gd**:断言函数直接追加进既有薄壳(宿主已自持 `check(cond, label)` 通道与 `_failures/_checks` 计数,摘要行 `[system_test] 摘要:N 项检查,M 项失败` 已被 run_system_test.py 三闸识别)。迁移期单跑验证:`python tools/run_system_test.py --script res://tests/system_test.gd`。**闸 01 缺省宿主在迁移期不动**(仍吃 smoke_all.gd),全部迁完的收尾批才把 `tools/run_system_test.py` 缺省 `--script` 改指 `res://tests/system_test.gd`(其头注既定程序)。 +- **夹具前提**:新宿主须自建 `_db/_rng` 等实例成员(smoke_all 的 `var _db/_rng` 由 `_initialize` 赋值)——照单元惯例显式实例化 ConfigDB/RngService 构造注入(rng_stream_boot_test 即 S01 本体的夹具形态);`AutoPlayer`/`level_autoplay` 等 smoke 库件迁移期继续按现路径 `res://smoke/...` preload,目录收尾批改名时同 commit 改 preload 路径。 + +## 六、歧义套(留主会话裁决,波 0 未定夺) + +| 套 | 波 0 倾向 | 拿不准的理由 | +|---|---|---| +| _initialize | 单元 → data_config | 13 条(表加载+RNG 向量/流语义)是标准单元域且现役 rng_service_test.gd 同域;但 :70 事件日志落盘一条带落盘味;且它是入口编排函数,编排壳(_run_core_smoke/_split_tail 调用序)不随迁而随源文件退役——「整套迁 data_config 一个宿主」还是「按域拆两宿主」留裁。 | +| _smoke_feats | 单元 → core | 主体 68 条=效果引擎逐件直测(纯逻辑);但 1420–1426 三条断点往返跨 SaveManager 落盘(集成味)。套级定单元、三条子块拆不拆留裁。 | +| _smoke_save | 单元 → core | 与现役 unit/core/save_*_test 同惯例(临时档隔离);但 6807–6831 摘要集成块驱动真实 run_adventure 全局再入账(adventure×save 两系统落盘)——该子块是否升集成留裁。 | +| _smoke_global_theme | 单元 → presentation | 依赖 ThemeDB 项目主题在座;`--script` 模式下 `ThemeDB.get_project_theme()` 能否取到项目主题未实跑验证(波 0 禁跑游戏测试),若取不到则此套须降级系统测试。主刀迁 G11 时先单跑验证。 | +| _smoke_type_gate / _smoke_hygiene_gates | 单元 → contracts | 断言对象是仓库自身文件(project.godot/static_scan.sh/产品码源码),归「契约」语义最贴;但现役 unit/contracts/ 定位是「系统间接口契约」——新设 source_guard 宿主还是并进别处留裁。 | +| _smoke_node_map_templates | 集成 | 跨四系统且走落盘判据明确;但前半 8 条(RNG 词表/模板池结构/抽签确定性)纯表结构可单元——整套集成还是拆套留裁。 | +| _smoke_playtest_logger | 单元 → presentation | logger 自持临时档;但它挂在编排器真实结算流上(跨系统)——单测/集成两可,倾向单测(被测主体唯一)。 | +| _smoke_type_glow | 单元 → presentation | 现役 unit/presentation/type_glow_test.gd 已覆盖同域微光——新宿主文件与它的分工边界(并列新文件还是后续合并)留主刀代理按「既有文件零改动」红线执行时定。 | + +## 七、附录 A:引用普查清单(全仓 grep `game/smoke`/`res://smoke`/`smoke_all`,reports/ 历史件与本账本自身除外) + +**合同在案必改点核实(§七批 B 第 4 条清单逐条)**: + +| 在案点 | 实况 | 核实 | +|---|---|---| +| run_all_gates.sh PARITY_TOUCH_FACES | :200–210 定义块,`game/smoke/adventure_monte_carlo.gd` 在 :209(合同写 :206——行号已漂 3 行,重核时以符号搜为准) | 在案 ✓ | +| AGENTS.md 在册入口 | :20(run_system_test 过渡宿主+探针路径)、:21(调参批量两件)、:22(verifier_batch 采集端) | 在案 ✓ | +| tools/parity_anchor.py | :55 `res://smoke/adventure_monte_carlo.gd` | 在案 ✓ | +| tools/balance_verifier.py | :54 `SCRIPT = "res://smoke/verifier_batch.gd"` | 在案 ✓ | +| sim-data/analyze.py | **零命中**(仅 :182 注释提「smoke/calibrate_boards.gd」字样)——合同虚列或在案即此注释行,收尾批顺手核 | 在案但弱 ✎ | +| architecture.md | :189(run_system_test 过渡宿主)、:241(三层承载表)、:242(UI 探针路径)、:315(启动后置条件判据事实源指针) | 在案 ✓ | +| design/gdd/术语表.md 豁免行 | :128「被检查的禁字不算使用」节引 smoke_all.gd contains("旗") 例 | 在案 ✓ | +| 08d 在册行号指针 | 08d=doc_ref_audit(run_all_gates.sh:192);其校验的行号指针散在设计文档——实测三处:锻造工坊.md:131(引「smoke_all.gd:1199 断言同步」史录)、数据配置.md:95(黄金向量断言指针)、体验验证清单.md:83(引函数名 `_smoke_flag_irreversible_quadrants`——**已滞后**,现名 `_smoke_seal_irreversible_quadrants`) | 在案 ✓(+1 处滞后名) | + +**合同清单之外的新发现(迁移/收尾批必改或须知)**: + +| 文件:行 | 性质 | +|---|---| +| game/presentation/tune_sim_controller.gd:27 | **产品码** preload `res://smoke/level_autoplay.gd`——smoke 目录改名时必须同批改,否则产品运行红 | +| game/tests/unit/presentation/pacing_queue_test.gd:53 | 现役单测 preload `res://smoke/auto_player.gd`——同上 | +| tools/probe_gate.sh:89–148(10 处) | 探针场景路径 `res://smoke/*.tscn`(合同 §四「批 B 随目录改名改路径」覆盖,列出防漏) | +| tools/render_gate.sh:56/72/96(3 处) | 同上(渲染探针路径) | +| tools/golden_baseline_refresh.sh:39/64(2 处) | **新发现**——golden 基线刷新脚本的探针路径,与 render_gate 同面 | +| tools/t3_lever_scan.py:74 | **新发现**——调参扫描脚本调 `res://smoke/adventure_monte_carlo.gd` | +| sim-data/README.md:36 | **新发现**——MC 采集档位表引 `game/smoke/adventure_monte_carlo.gd` 路径 | +| game/tests/unit/README.md:6 | 参考实现指针「`game/smoke/smoke_all.gd`(752+ 条断言…)」——迁移后改指新宿主并顺带订正滞后读数 | +| game/tests/unit/{rng_service_test:4, level_map_type_vis_test:4, curio_refresh_paid_test:26} | 注释提及 smoke_all(黄金向量锚分工/白盒先例)——收尾批顺手改提法 | +| game/tests/static_scan.sh:100 | S11 去重闸头注提 smoke_all.gd(批 A 加的豁免说明——smoke_all 删除后改提新宿主) | +| game/tests/.gates/baseline.json note | 历史记事文本含 smoke_all 字样(记事性引用,非路径依赖;随闸况重生成自然换代) | +| game/tests/README.md(11 处) | 过渡期口径句(层级表/游离台账/运行示例)——收尾批统一换代 | +| game/README.md:8/21/27/33/41(5 处) | **新发现**——工程 README 的运行命令全是 smoke_all 与 res://smoke 探针路径 | +| 待办清单.md:570/656/658、体验验证清单.md:83 | 流程清单条目与滞后函数名(体验验证清单:83 函数名已滞后,见上) | +| design/engineering/test-conventions.md:3/36 | 约束范围句与执行载体清单提 smoke_all.gd/smoke/ | +| design/gdd/{锻造工坊:131, 数据配置:95} | 设计文档指针(锻造工坊为史录句;数据配置为活性指针须改指) | +| game/smoke/*.gd 自引(各文件头注 res://smoke 路径,约 30 处) | 目录收尾改名时同批改;非闸件的自引属文件自身头注 | +| tools/run_all_gates.sh:166 | 注释「过渡期宿主=game/smoke/smoke_all.gd」——收尾批同步 | + +普查口径:`grep -rnE "game/smoke|res://smoke|smoke_all"` 限 .py/.sh/.gd/.md/.godot/.cfg/.json,排除 reports/、archive/、.git/ 与本账本/清单自身;总命中文件 63 个(上表归并后 18 类)。 + +## 八、其他需主会话注意 + +1. **清单入库面**:`game/tests/验证集清单.md` 已过 static_scan S11(205 文件词表扫描零命中)——唯一含退役词字面的标签(:6304「重判」行)与「去重闸」同行被既有豁免覆盖,**未新增任何豁免条款**。 +2. **工具面**:`tools/extract_checks.py` 为常驻对账工具(非本批中间件);其熵增闸归属=tools/ 现有类别,无新目录。它依赖码位掩码解析,smoke_all.gd 冻结期内源文件变动(不该发生)会直接反映在 diff。 +3. **收尾批提示**:smoke_all.gd 拆空删除时,`game/tests/smoke/` 子目录(critical-paths.md 人工闸清单)与 `game/smoke/` 目录改名是两件事,别漏前者;baseline.json 01 键 note 在收尾批换宿主时随批更新。 +4. **释义列的性质**:白话释义由波 0 逐条人工撰写(非机器生成),是对冻结面的可读注解;后续维护只增改释义不算改清单冻结面(--diff 不比对释义/处置列)。 + +## 七、主会话裁决(2026-10-22,波 0 §六 歧义 8 项闭合) + +1. **_initialize**:拆两宿主——纯 data_config 断言迁单元 data_config;:70 事件日志落盘条迁集成。 +2. **_smoke_feats**:拆两宿主——主体效果引擎迁单元 core;:1420–1426 三条落盘往返迁集成。 +3. **_smoke_save**:拆两宿主——主体照 save_*_test 惯例迁单元 core;:6807–6831 真实 run_adventure×落盘块迁集成。 +4. **_smoke_global_theme**:先按单元 presentation 迁移并实跑验证;若 --script 取不到 ThemeDB 致断言失真,整体降级系统测试宿主(实测裁决,实跑证据记入账本)。 +5. **_smoke_type_gate/_smoke_hygiene_gates**:迁单元新设子目录 `source_guard/`(被测对象=仓库自身源文件,名实相符)。 +6. **_smoke_node_map_templates**:整套集成,不拆(跨四系统且落盘,拆套徒增账目)。 +7. **_smoke_playtest_logger**:集成(挂真实结算流的跨系统行为)。 +8. **_smoke_type_glow**:全量迁单元 presentation,与现役 type_glow_test 同域共存;重复面记「另批去重候选」,**本批禁删任何断言**(验证集不缩小为绝对约束)。 + +附(普查新发现落位,波 2 执行):`game/presentation/tune_sim_controller.gd:27` 与 `unit/pacing_queue_test.gd:53` 的 `res://smoke/` preload 指向 autoplay 驱动件——两类件随波 2 入 `game/sim/`,preload 同步改指;`体验验证清单:83` 旧函数名一并清扫;合同必改点修正——PARITY 触面实位 :209(合同写 :206 漂 3 行),`sim-data/analyze.py` 零命中从必改点除名。 + +## 九、波 1 施工记录(逐组追加;G1 —— 2026-10-22 波 1 主刀代理) + +**G1 系统·启动链尾段 → game/tests/system_test.gd:六套 49 条全部迁入,原子 commit 一笔。** +挂载终态=`_split_tail` 剪切形态(autoload 检查+四协程套依序 await+摘要 quit);G12 两套调用(_smoke_type_gate/_smoke_hygiene_gates)不随迁。helpers `_topmost_visible_layer/_face_snapshot/_face_liveness` 随用套迁入;`AutoPlayer` 按现路径 `res://smoke/auto_player.gd` preload(账本 §五夹具前提)。smoke_all.gd 同批删六函数+三 helper,`_split_tail` 瘦壳只留 G12 两套调用+摘要 quit。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_scene_split`(11 条):11/11 验红。变异点——:162 menu_screen.gd careerbtn 置 null(红,连带 V1c);:164 menu_screen.gd 尾加 start_overlay.visible=false(红唯一);:165 mouse_filter 改 STOP(红唯一);:170 camp_screen.gd camp_overlay 置 null(红,null 访问中断后续属预期形态);:171 camp_screen.gd 尾加 visible=true(红,连带 V1a);:176 adventure_screen.gd stage 置 null(红唯一);:187 main.gd:199 启动态 visible 翻 false(红唯一);:191 LAYER_CONTRACT 前两 shell 行对调(红,连带 V1a);:209 LAYER_CONTRACT legend_overlay key 改错名(红唯一);:211 main.gd:228 level_screen 句柄置 null(红;首试 board_view=null 因后续 set_identity_resolver 崩链过早、:211 未达,换点后成);:213 main.gd:197 闲时会话参数翻 true(红唯一)。 +- `_smoke_boot_chain`(15 条):14/15 验红。变异点——:250 config_db.gd:139 hero 置 null(红,连带 V1c);:255 settings_store.gd 音量 db 偏移 +1.0(红唯一);:259 motion_scale 两支对调(红唯一);:263 main.gd:199 翻 false(红,连带 V1a);:269 _refresh_resume_button has 恒 true(红,连带 :287/:297);:273 save_manager.gd:83 active_slot() 访问器恒返 1(红唯一);:275 keystone_balance() 偏移 +3(红唯一);:277 write_to_disk 无激活拒绝分支返 true(红唯一);:287 `_resume_btn.visible = not has`(红,连带 3 条同面);:292 恒 false(红唯一);:297 clear_breakpoint 改 pass(红唯一);:300 main.gd:197 翻 true(红,连带 :213);:304 _ready 尾插 debug_overlay.visible=true(红,连带 V1a);:306 badge 恒 false(红唯一)。 +- **:279 死断言候选(唯一一条,照迁禁删)**:「V2-s4 旧单档迁移不变式」——本机磁盘态已固化(save.json 与 save_slot_1.json 均在档=迁移已完成),cond 的可红面(旧档在而槽 1 缺)在生产码变异下不可达;三形态结论:迁移调用行注释=cond 恒真不红;slot_path 派生改坏会触发 _ready 真迁移副作用(碰真实档,红线禁止);迁移停摆+槽派生两处组合=save_manager 内部消费链崩、断言不可达。护栏语义在 fresh 环境有效(旧档存在且未迁时必红),本机环境无法复现该态。 +- `_smoke_run_lifecycle`(11 条):11/11 验红。变异点——:608/:638 main.gd:607(_start_adventure 内 `_begin_session(true)`)翻 false 两独立轮(各红);:610 run_scope.gd hints_shown 预置(红,连带 :630/:638);:619 adventure_runner.gd:830 abandon 返 false(红唯一);:624 :970 emit 注释(红,连带 :625);:625 battle_end_overlay.gd:165 is_open 恒 false(红唯一;首试注释 main.gd:700 else 体致 Parse Error 不算红,换点);:628 main.gd:727 收束会话翻 true(红唯一);:630 run_scope.gd boss_active 预置 true(红唯一);:632 main.gd:729 翻 false(红唯一);:634 adventure_runner.gd:967 clear_breakpoint 注释(红,连带 :638/:640);:640 main.gd:499 翻 true(红唯一)。 +- `_stage_faces`(1 条):1/1 验红。变异点——main.gd:383 `start_visible_get` 回调取反(红唯一,tag「V3a 闲时」)。 +- `_smoke_lifecycle_matrix`(10 条):9/10 验红。变异点——:374 battle_orchestrator.gd:1224 is_battle_live 恒真(驱动跑满 guard 600 红唯一);:388/:453 abandon 返 false 两独立轮(各红);:399 :970 emit 注释(红,连带 4 条);:405 main.gd:727 收束会话注释(红,连带多条);:408 _begin_session 的 queue_free 改 pass(红唯一;注释形态 Parse Error 不算红,换 pass);:421 **死断言候选(照迁禁删)**——menu_screen.gd campbtn 置 null 后 boot 的信号接线先崩(SCRIPT ERROR 中断 _build_ui),:421 护栏在执行序上被遮断不可达;其拦截语义由 boot 崩与前置断言代偿;:450 断点残留形态(:967 clear_breakpoint 注释,第二局走确认框分支不开局→adventure null)验红(首试 wire 断链因 run_lifecycle 先中断不可达,换形态);:479 `_resume_btn.visible` 恒 false(红,连带 :292);:482 main.gd:550 resume 不换会话(红唯一)。 +- `_split_tail`(1 条):1/1 验红。变异点——project.godot SaveManager autoload 行注释(走 else 分支,红唯一,1 项检查 1 失败)。 + +窄复验:新宿主面 65 项检查 0 失败 PASS(运行计数 65>静态 49,因 _stage_faces 每阶段调用各计一次——smoke 原生特性);闸 01 剩余面(smoke_all.gd)1146 项检查 0 失败 PASS;闸 02(run_all.gd)40 套件 all green。标签逐字抽查 6 条与冻结清单全对齐;新宿主 check( 调用计数 49=账本条数。 + +施工发现(供后续组参考):① Git Bash sed 的 `a\` 追加与含块结构注释变异易产 Parse Error(不算红),整行 `pass`/python 插入替代;② save_manager.gd `_init` 内对成员的赋值会被成员初始化器时序覆盖(probe 实证 active_slot 恒 0),变异请选访问器面;③ main.gd `_begin_session(true)` 有两处(_resume_from_menu:550、_start_adventure:607),awk 首匹配会错定位,变异前必核行号上下文。 + +**G2 单元·表结构族 → game/tests/unit/data_config/table_structure_guard_test.gd:七套 60 条全部迁入,原子 commit 一笔。** +挂载终态=宿主 `_init` 自建 `_db`(ConfigDB 构造注入,`load_tables(..., true)`;加载失败**不中止**沿被迁断言面的源语义——诊断展开后表损坏的红落在具体断言上,与单元惯例「load 失败即退」不同,因表值变异须落断言红);套执行序沿源编排序(ac_swap→enemy_bands→variation→difficulty→nine_types→adventure_table→meta,difficulty 的修饰行段内自清空不泄)。helper 随迁(首用套全在本组):`_fresh_rng/_meta_new_save/_errs_has_cross_field`(static,无 `_db` 依赖——后续宿主可 preload 复用)+`_boss_depth/_first_encounter_band/_level_active_nodes`(实例法,依赖 `_db`)+`const _META_SMOKE_PATHS` 全 18 路径。**共享 helper 在 smoke_all.gd 原地保留**(G3–G11 残余套仍消费 `_fresh_rng/_boss_depth/_first_encounter_band/_level_active_nodes/_errs_has_cross_field/_meta_new_save/_META_SMOKE_PATHS`;宿主副本为过渡态双份,收尾批拆 smoke 侧时去重)。`_smoke_meta` 剪切形态=瘦壳(六调用保留:_meta_pricing_and_buy/_meta_settle_and_workshop/_meta_injection/_smoke_forge_axes/_status_panel_projection/_career_overlay_projection——G9/G11 未迁)。smoke_all.gd 同批删六函数+`_run_core_smoke` 六调用行,`_smoke_meta` 瘦壳。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_adventure_table`(17 条):17/17 验红。变异点——:306 config_db.gd:147 adventure 装配行 pass 化(红唯一,early return 后续套内断言不跑属预期);:309 adventure.tres curio_candidate_count 3→0(validate [1,6] 违例,红唯一);:310 clear_heal_pct 20→150(红,连带 :309);:313 attack_common 配额 18→17(红,连带 :315/:309);:315 两点一致变异:配额 18→17+adventure_table.gd TIER_QUOTAS common 70→69(红唯一——锚同移则 :313 不红);:316 gp_common 值 20→21(红唯一);:321 ap_rare 1→2(红唯一);:323 adventure_table.gd:85 默认 curio_cap_gp 99→50(红唯一——cap 无表行走 schema 默认);:325 attunement.gd slots 的 null 分支返 1(红唯一);:332 treasure_items.tres morning_order 行插 `"retired": true`(红,连带 :343/:371/:414——attunement_rows 过滤 retired);:343 display_name 晨操令→晨操令改(红唯一);:371 trial_pick tier common→rare(红唯一);:374 battle_orchestrator.gd:367 attunement 注入行 pass 化(红唯一);:378 `_attune_has` 恒真(红唯一,:374 保持绿);:393 effect_kinds 加 "rewrite_board"(红唯一);:400 两 plan 行(refresh_practice/refresh_license)tag 同改 info(红唯一;首试单行变异不破「三方向各有在册」不变式=无效变异);:414 meta_progression.gd treasure_shop_status 的 unlocked 恒 true(红唯一)。 +- `_smoke_enemy_bands`(4 条):4/4 验红。变异点——:1615 L2 band1 bugbear depth_band 1→2(红唯一);:1622 hobgoblin depth_band 3→2(红,连带 :1615/:1811 曲线比 3.50);:1625 删表尾「hobgoblin 行为缺口行」注释行(红唯一);:1642 ogre_chief depth_band 4→5(红,连带 :1615)。 +- `_smoke_ac_swap_guard`(12 条):12/12 验红。变异点——:1718 status_effects.tres 表首复插 defense_up 行(红唯一);:1720 ac_up magnitude 1→2(红唯一);:1728 reward.tres defense 正向行 effect_id→heal(红唯一);:1730 settlement.tres effect_pool_map 加 ac_up 键(红唯一);:1745 两点变异:effect_pool_map 加 weaken→hero_defense(红唯一,消息展开 weaken(-1040);**首试单抬 pool_clamp_lo=无效变异——现表无 hero_defense 池项,护栏循环体零执行**);:1748 hero.tres armor_class 35(红唯一);:1765 L1 band0 goblin armor_class 9(红唯一);:1773 drider armor_class 10→7(红,连带 :1765);:1789 L1 band2 bugbear armor_class 2→3(红唯一);:1811 ogre_chief hp 72→720(红唯一,带比 20.57);:1816 roll_faces 20→10(红唯一);:1820 roll_tier_bounds [3,7]→[2,7](红唯一)。 +- `_smoke_variation_framework`(9 条):9/9 验红。变异点——:2078 variation.tres 删 rockfall 整行(红唯一);:2079 magma unlock_level 2→0(红,连带 :2088);:2083 thorn unlock_level 1→2(红唯一);:2087 magma unlock_level 2→3(红唯一);:2088 ash unlock_level 2→1(红唯一);:2092 magma board_types 加 "dungeon"(红唯一);:2093 variation_table.gd row_by_id 未命中返 `{"variant_id": 入参}`(红唯一);:2099 validate 的 flat_damage placement 互锁比较目标 closed_only→any(红,连带 :2079;首试把比较常量改成不可匹配值使违例恒真=方向反,红落 :2079 非目标,翻转后成);:2103 hide_number 互锁 `!=`→`==`(红,连带 :2079)。 +- `_smoke_difficulty_table`(8 条):8/8 验红。变异点——:2673 difficulty.tres 预置 (L2,1.0,1.0) 行(红唯一);:2676 unit_container.gd 空修饰表拒 spawn(红,difficulty 套内 index 中断+:374/:378 两断言经残留修饰行级联红——中止路径跳过 rows.clear() 所致);:2679 空修饰路径 hp 偷 +1(红唯一);:2683 双乘数 >1 拒 spawn(红,同级联);:2685 hp 误用较大乘数 atk_mult(红唯一);:2686 atk 翻倍仅限 atk_mult>1 行(红唯一;**首试 atk 误用 hp_mult 被 round 舍入吞(1×1.5→2=期望 2)=无效变异**);:2691 hp_mult≥10 拒 spawn(红,同级联);:2692 config_db_resolver.gd hp 钳上界 10→12(红唯一,8×12=96)。 +- `_smoke_nine_types_gp_stream`(6 条):6/6 验红。变异点——:4986 reward_table.gd CELL_TYPES 删 "gp"(红唯一);:5001 heal_backlash pool_tag→clear_default(红唯一);:5007 heal_backlash power 6→7(红唯一);:5020 ac_up 正向行 weight 11→0(红唯一);:5022 gp_coin weight 2→3(红唯一);:5024 rng_service.gd set_master_seed 尾 `_counter = 0`→`= 1`(红唯一;**首试 derive_at 塞计数=不可达——新建+播种不路经 derive_at,该条守「播种后计数恒 0」面**)。 +- `_smoke_meta`(4 条):4/4 验红。变异点——:5032 meta.tres conversion atk 平移量 9→101(域 [0,100] 违例,红唯一);:5034 删 attune_refresh_upgrade 第二档(连续从 1 仍成立,红唯一);:5040 conversion attr max_hp→hp(红,连带 :5032);:5045 L3 first_kill 3→2(红,连带 :5032)。 + +窄复验:新宿主面 65 项检查 0 失败 PASS(静态 60+循环展开 5=档位循环×4、mine_power×3);闸 01 剩余面(smoke_all.gd)1081 项检查 0 失败 PASS(1146−65=本轮迁出七套运行计数);闸 02(run_all.gd)41 套件 all green(+本宿主 1 件);run_smoke.py 系统级断言面 17 项 0 失败 PASS。60/60 标签与冻结清单全量脚本比对零缺失;新宿主 Kit.check( 调用计数 60=账本条数。 + +施工发现(供后续组参考,接 G1 序号):④ 共享 helper 迁移形态=宿主复制+smoke_all.gd 原地保留双份(残余套仍消费;本波各组禁从 smoke_all.gd 删共享 helper,收尾批统一去重);⑤ 表值变异若触发表内 validate(配额锚/AC 带宽/互锁等),在「夹具 load 失败不中止」形态下红自然落到断言,无需两点变异;仅当变异被断言外机制吞掉(round 舍入/不变式未破/执行序不可达)才换变异点,并在账本记明无效变异首试;⑥ 宿主 fixture 与现役单元「load 失败即退」惯例不同(沿源语义不中止),后续 data_config 侧新宿主按各自断言面对齐源形态选择。 + +**G3 单元·棋盘生成与资源 → game/tests/unit/core/board_gen_reward_test.gd:四套 39 条逐套原子提交(本组起组内按套提交)。** +挂载终态=宿主 `_init` 自建 `_db`(ConfigDB 构造注入,`load_tables(..., true)` 失败不中止承 G2 同款)+`_rng`(RngService master 种子 42 沿源,全新取号计数);套执行序沿源编排序(layout_cell_formula→reward_identity→board_generator→power_interval)。helper 随迁:`_mk_stream`(实例法,依赖 `_rng`;smoke_all.gd 原件保留——G11 `_smoke_type_glow` 仍消费)+`_board_dump`/`_row_power`(实例法,依赖 `_db`)+内嵌类 `_BoardRowStub`;`BoardData`/`RewardTable` 全局 class_name 裸用(--script 模式全局类在册,G2 宿主同先例)。manifest 登记随首套 commit。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_layout_cell_formula`(2 条):2/2 验红。变异点——board_view.gd:233 删 `- float((n - 1) * sep)` 格间距项(bug⑥ 原缺陷形态;红唯一两条迭代齐红 L1 438/L2 442 > 436,档内条保持绿);board_view.gd:234 clamp 上界 46→25(该条守面=钳制带本体——夹具板实测格边 41/33 为带内值,带内改界不红,改界出带方验红;红唯一两条迭代齐红 25px < 26,网格高条保持绿)。 +- `_smoke_reward_identity_unit`(10 条):10/10 验红(全部红唯一 1/14)。变异点——reward_economy.gd:89 roll_board 尾 return true→false(红「roll_board 受理」);:39 相位守卫 `_phase == "ready"`→`"never"`(红「重复 roll_board 被拒」);:113 投影删 tier 键(红「query_identity 投影」);:111 清单外出口 return null→{}(红「清单外坐标查询」;null→{} 形态避免 `_mapping[cell]` 缺键 crash);:140 grant 幂等缓存出口改扰动副本 duplicate+stacks=99(红「同格重复 grant 幂等」——重算快照值相等,非扰动副本形态红不了);:137 grant 清单外出口 return null→{}(红「清单外 grant 拒绝」);:87 `_clear_identity = clear_rid`→`"gp_coin"`(红「清图批尾身份」,power 掷定仍循真 clear_rid 故 :1977 power 条保持绿——隔离成功);:85 `_clear_power` 区间掷点→`power_min - 1`(红「清图奖励 power ∈ 表区间」roll 23 < 24);:152 grant_clear 幂等缓存出口改扰动副本(红「grant_clear 同批次重调幂等」);:163–164 两点变异 `_phase = "retired"` 与 `_mapping.clear()` 双双 pass 化(红「映射退役后 grant 拒绝」——该条守面=相位门+映射 wipe 联合,单点均被另一机制代偿必须两点)。 +- `_smoke_board_generator`(22 条):22/22 验红(零死断言候选)。变异点——config_db_resolver.gd:156 行匹配 return row→`return {}`(红「BoardGen 行解析」双迭代+generate 行缺失断言链连带:「产物非空」「R1 ceil」同轮红——resolve 与 generate 共用单点不可分);**首试无效变异**:_ceil_key `return int(k)`→`int(k) - 1` 键值偏移致 level 键错位空 depth_keys 数组越界 crash(Parse 级污染不算红),换形 depth_ceil 路径插 `if missing == "depth_ceil": return {}`(红「R1 ceil 解析」红唯一 1/57);board_generator.gd:75 return accepted→null(红「生成产物非空」双迭代+suite1 rebuild 空板访问中断连带——预期形态);:278 is_clearable 尾 return true→false(红「clearable」双迭代+legality 内嵌消费链连带 6 条:legality 双红/accept_worst 链 inferability/非法产物/R₀ 结构);:282 legality 头插判别器 `if params.has("node_depth"): return false`(红「legality 全过」双迭代红唯一——generate 内部 params 无 node_depth 不受扰,防三分出口链连带);:340 指标 inferability→0.0(红「inferability ≥ target」L1 迭代红唯一——L2 原生 accept_best 走右支保持绿);:354 后插雷位格放变异 `for c in b.mines.keys(): b.variants[c] = []; break`(红「零变体格」双迭代红唯一——雷位格不入 spawn/R₀ 合法性扫描域);:346 指标删 degraded_product 键(红「指标集输出」双迭代红唯一——板字段独立在 :354 故三分出口条零连带);:354 板 degraded_product→true(红「降级标记」L1 红唯一——L2 原生 accept_best 与恒真恰一致);:353 板 illegal_product→true(红「非法标记==」双迭代+A14 双红+L2 互斥连带);:353–354 双标记恒 true(红「互斥」双迭代+A9/A10/A14 连带);:341 指标 actual_mines+10(红「实际≤目标」双迭代——真实雷数不动绕开 _finalize 内同不变式 _fatal 守卫);:344 指标 attempts_used→0(红「attempts 域」双迭代红唯一);:353 板 illegal_product 取反(红「非法标记=false」双迭代+A10 双红+L2 互斥+A21 b9 诚实降级标记连带);:100 取号换全局未播种 randi_range(红「同种子逐格相同」双迭代红唯一);:100 取号恒 i 恒等置换(红「异种子不同」+退化板 legality 链连带——板全同构时退化板本身非法属必然形态);board_gen.tres L1 depth0 行 r0_max_cap 45→5(红「cap 投值 ≥9」+cap 闸压全尝试 accept_worst 链连带 2 条);:349 指标 r0_count+100(红「R₀≤cap」双迭代红唯一——b9 illegal 支路承载故 A21 零连带);:351 指标 r0_cap_rejected→-1(红「尝试事实在位」双迭代+A22 双红连带);:348 指标 ring_removed→999(红「剃雷量界」双迭代红唯一);:316 r0_cap 闸条件 >→<(红「cap 压线 9」双迭代+L2 结构断言连带——闸关后高 R₀ 板入收);:345 指标 accept_mode→""(红「指标集完整」双迭代+L2 A6/A9 连带——右支与降级比较均读该指标)。 +- `_smoke_power_interval`(5 条):4/5 验红+**1 条死断言候选(照迁禁删)**。变异点——reward.tres clear_blast 行 power 32→33(偏离 floor 中点;红「区间行配对齐全」红唯一 1/62);reward_economy.gd:85 清槽掷点 hi+5(红「24 板域内」红唯一——仅清槽支路格掷点不动故零连带);:353 _snapshot 定值行支路 `(int(row.power) + 1) if rolled_power < 0`(红「定值行零外溢」红唯一——区间行走 rolled 支路不受扰);:82+:85 双掷点恒取下界(**流动彻底消灭仍全绿=死断言取证**)——「区间行上下阶实际流动」守面 `elif samples >= 2 and seen[rid].size() < 2` 中 `samples` 与 `seen[rid].size()` 为同一字典的同一读数(`seen.get(rid, {})` 与 `seen[rid]` 同引用),条件恒假 variance_ok 不可置假;该缺陷系 smoke_all.gd 源码自带(非迁移引入),断言逐字照迁,死断言候选上报主会话裁;:82 格掷点 hi+1(红「定值行(lo=hi)roll 恒等表值」+「24 板域内」连带——heal_minor [2,2] 行任何失恒值必出域,两断言语义重叠连带不可避免)。 + +窄复验:新宿主面 62 项检查 0 失败 PASS(静态 39+循环展开 23=格边公式/棋盘生成两套 L1×L2 迭代展开);闸 01 剩余面(smoke_all.gd)1019 项检查 0 失败 PASS(1081−62=本轮迁出四套运行计数);闸 02(run_all.gd)42 套件 all green(+本宿主 1 件)。39/39 标签与冻结清单全量脚本比对零缺失;新宿主 Kit.check( 调用计数 39=账本条数。 + +施工发现(供后续组参考,接 G2 序号):⑦ 删套函数必须同批删 `_run_core_smoke` 调度体里的调用行——本组首轮删净四函数后闸 01 即 Parse Error「Function not found in base self」(调用行残留致红,三闸之二挂),补删四调用行后全过;G1/G2 记录有载此步(「同批删六调用行」),G3 首轮遗漏,后续组删套时以「函数+调用行」双检为鉴。 + +**G4 单元·掷点与乘区 → game/tests/unit/battle_settlement/roll_multiplier_test.gd:五套 36 条逐套原子提交(沿 G3 起组内按套提交)。** +挂载终态=宿主 `_init` 自建 `_db`(ConfigDB 构造注入,`load_tables(..., true)` 失败不中止承 G2/G3 同款);各套自建 RngService(源套掷点隔离形态——逐战斗全新取号,宿主不持统一流成员)。helper 归属:`_first_hero_attack_rows/_weaken_refresh_probe/_ac11_tier_samples`(armor_roll 套内独占)、`_hero_attack_row_with`(pool_wiring 套内独占)、`_run_seal_backlash_round`(damage_types 套内独占)随迁且 smoke_all.gd 同批删原件;`_find_seal_target`(G5 未迁四套仍消费)与 `_run_battle/_auto_play_round`(G6 属主)双份过渡——宿主持一致副本、smoke_all.gd 原件保留(发现④),`_run_battle` 副本去 G6 专属「收束相位回 Idle」断言(该条随 G6 迁其宿主,副本保持零断言纯驱动形态)。`DamageMath` 全局 class_name 裸用(G3 同先例)。manifest 登记随套1 commit。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_enemy_attack_type`(4 条):4/4 验红。变异点——enemy.tres:26 goblin attack_type normal→pierce(红唯一 1/4,越轨行 goblin:pierce);unit_container.gd:110 快照注入硬编码空串(红 3/4——快照为载荷/矩阵共同源,条3 条4 连带属预期形态);damage_controller.gd:471 敌方技载荷 attack_type 置空串(红唯一 1/4);damage_controller.gd:577 `_counter_delta` 查表短路恒中性(红唯一 1/4)。 +- `_smoke_armor_roll`(15 条):15/15 验红(条3 含一次无效首试换点,共 16 轮)。变异点——damage_math.gd:36 档位边界比较 `>`→`>=`(红「查档分布」双迭代+「EV 锚」连带——纯函数共面);settlement.tres roll_tier_discounts 75→74(红「EV 锚」1572≠1575,连带查档双迭代+AC11 落域);damage_controller.gd:540 暴击判定恒假(**首试无效变异**——「nat20 种子可达」只查掷点值==crit_roll 不查标记,判路面不被触及;该轮实际验红条4/条5 记为下两条的顺带证据)→换点 :539 roll 上界收窄 20 不可达(红「种子可达」seed=-1,连带条4/条5——种子扫描共同源);:375 hero 载荷 crit 标记写入恒假(红「crit 标记留档」红唯一 1/20);:369 暴击倍率传参 200→100(红「普通分量 ×2」红唯一);status_container.gd:52 续时刷新分支 pass 化(红「削弱续时」红唯一);:40 幅度快照 −1 偏移(红「削弱池读数=行值」红唯一);damage_math.gd:38 档值 +7 偏移出域(红「AC11 逐拍落域」,连带查档双迭代+EV 锚——纯函数共面;「折扣档流动」保持绿);damage_controller.gd:539 掷点折叠恒 nat 面(红「折扣档流动」——采样 distinct 恒 1 穷尽返空 ×0 拍连带「AC11 逐拍落域」);:515 board_blast 下限拆除恒 0(红「零 v=0 伤害行」红唯一——流中确有群伤行);:374 hero 载荷 tier 键 pass 化(红「掷点读数留档」红唯一);:504 board_typed 载荷塞 roll 键(红「棋盘行零掷点字段」红唯一);damage_math.gd counter_component 三点隔离变异:贴底特例 `sum==1→99`(红「贴底下限」红唯一)、负和特例 `sum<0→99`(红「触底穿透」红唯一)、空桶早退 `return 0→1`(红「空桶恒 0」红唯一)。 +- `_smoke_pool_wiring`(4 条):4/4 验红。变异点——damage_controller.gd:271 hero 侧 atk_type 加 "_x" 后缀(红「单分量基线在位」——payload 分量键改名 normal 键失位;armor_roll 套「普通分量 ×2」同键消费连带属预期形态);status_effects.tres attack_up magnitude 50→40(红「增伤 ×1.5」140≠150 红唯一);sapped magnitude −25→−10(红「乏力 ×0.75」90≠75 红唯一);fortify magnitude −50→−20(红「硬化折半方向」80≠50 红唯一)。 +- `_smoke_damage_types`(10 条):10/10 验红(条3 含一次无效首试换点,共 11 轮)。变异点——enemy.tres goblin 行 armor_type light→heavy:27(红「goblin 护甲」+armor_roll「普通分量 ×2」连带——goblin 是 crit 探针攻击目标);bugbear 行 armor_type light→heavy:73(红唯一 1/34);ogre_chief 行 armor_type light→heavy:126(**首试无效变异**——该条 spawn 直呼第 9 参 "boss" 实落 hero_armor_type 形参、row_kind 缺省 battle(源套形态照迁),(1,4) battle 域 band_of ceil 落带命中 hobgoblin band3 行、ogre_chief 属 boss 域不在键域)→换点 hobgoblin 行 armor_type light→heavy:98(红唯一 1/34——实证解析落带);settlement.tres normal.light 0→1:33(红「普通行矩阵值」+套1 矩阵证词/armor_roll ×2 连带——同表源);pierce.heavy 0→1:40(红唯一);magic.ward −1→1:44(红唯一);grant_segment.gd:312 归因行类型 seal_miss→seal_mis(红「seal_miss 归因行」×0 红唯一);:316 标错负向路由 pass 化(红「负向字典行」×0 红唯一);:290 标中正向路由 pass 化(红「正向字典行」×0 红唯一);damage_controller.gd:539 armor_roll 流换全局未播种 randi_range(红「同种子回放逐行相同」——G3 同款确定性击破形态;套1 矩阵证词连带属预期)。 +- `_smoke_attack_chain_fresh_pool`(3 条):3/3 验红。变异点——battle_orchestrator.gd:304 begin_battle 受理守卫 `!= 0`→`>= 0` 恒拒(红「fresh 池回归战斗启动」;该守卫为全宿主战斗共同入口,其余四套战斗面 16 项连带红属其自然半径);damage_controller.gd:479 敌技行类型 damage→damage_x(红「非施放攻击拍采样充足」×0 拍;套1 矩阵证词/载荷与套2 AC11 双条为敌方行共同源连带);:447 敌技乘区 enemy_output 池注值 50(红「fresh 池 d_tick 掷点口径」——pooled=floor(rolled×1.5) 偏离 fresh 池零池口径;套1 矩阵证词连带)。 + +窄复验:新宿主面 37 项检查 0 失败 PASS(静态 36+查档循环展开 1=AC10/AC12 双迭代);闸 01 剩余面(smoke_all.gd)981 项检查 0 失败 PASS(1019−38=本轮迁出五套运行计数 37+smoke_all 侧 `_run_battle` 原件内置 Idle 断言 1);闸 02(run_all.gd)43 套件 all green(+本宿主 1 件)。36/36 标签与冻结清单全量脚本比对零缺失零多出(复用 extract_checks 解析器、宿主侧按 Kit.check 反参序提取);新宿主 Kit.check( 调用计数 36=账本条数。 + +施工发现(供后续组参考,接 G3 序号):⑧ 剪切块必须以「节头/函数头至下一函数头」整块为界、删后必跑闸 01——本轮套5 首剪以行号配平差一行,游离 check 行残留 Parse Error(发现⑦同源再现:调用/断言行残留只有跑才现形);⑨ 直呼生产函数的变异定位先核签名——`spawn(..., "", "boss")` 九参实落 hero_armor_type 形参、row_kind 缺省 battle(套4 条3 首试无效根因),行集解析按 battle 域 band_of ceil 落带、boss 域行不在键域。 + +**G5 单元·封印与极性 → game/tests/unit/battle_settlement/seal_polarity_test.gd:八套 72 条逐套原子提交(沿 G3/G4 组内按套提交)。** +挂载终态=宿主 `_init` 自建 `_db`(ConfigDB 构造注入,`load_tables(..., true)` 失败不中止承 G2/G3/G4 同款);各套自建 RngService(源套掷点隔离形态)。helper 归属:`_find_seal_target/_run_full_clear/_drive_clear/_rows_at/_rows_of_type/_has_source_row/_has_effect_id/_run_polarity_probe/_polarity_probe_accepts`+常量 `POSITIVE_EFFECTS/NEGATIVE_EFFECTS/CLEAR_SMOKE_DEPTH/CLEAR_SMOKE_HERO_HP`+成员 `_regular_clear_reward_rows` 均为 G5 组内独占(消费面普查实证),随首用套迁入宿主、smoke_all.gd 原件在该 helper 最后一个 smoke 侧消费者迁走的那笔同批删;`_run_battle/_auto_play_round`(G6 属主)双份过渡——宿主持零断言纯驱动一致副本(G4 同款,副本去 G6 专属 Idle 断言)、smoke_all.gd 原件保留。`AutoPlayer` 按现路径 `res://smoke/auto_player.gd` preload;`RewardTable` 全局 class_name 裸用(G3 先例)。manifest 登记随套1 commit。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_seal_ap_exhausted`(5 条):5/5 验红。变异点——battle_orchestrator.gd:304 begin_battle 受理守卫 `!= 0`→`>= 0` 恒拒(红 5/5——开局受理为全套共同入口,连带属自然半径);action_economy.gd:128 dig 扣费 `_ap -= cost`→`-= cost + 1`(红「恰剩 1 AP」红唯一 1/5——宿主挖掘循环 break 守卫下实测 ap 停 2,封印受理与轮末不受扰);minesweeper_rules.gd:59 `revealed_cells()` 置空 `return []`(红「仍存在可挖目标」4/5——choose_dig 共同源,挖掘循环首轮 break 致条2/4/5 连带属预期形态);action_economy.gd:73 `_cost` seal_place 计费 `return ae.seal_place_ap_cost`→`return 99`(红「末点 AP 封印被受理」2/5——计费付不起被拒,条5 连带=受理失败轮末不触发属预期);action_economy.gd:149 `_spend_and_arbitrate` 耗尽仲裁 `elif _ap == 0`→`elif _ap < 0` 恒假(红「触发轮末结算进第 2 轮」红唯一 1/5——seal 走 try_spend 路径仲裁、dig 走 `_spend_cost_and_arbitrate` 另一处不动,隔离干净)。 + +## 十、支援线 9 项待裁闭合(2026-10-22 主会话裁) + +1. `extract_checks.py` **常驻保留**——承担清单维护义务(test-conventions §9.3 锚)的再提取/diff 职能;其 DEFAULT_SRC 悬空问题列入波 2:默认源改为三宿主(game/tests/unit、integration、system_test.gd)遍历,或显式路径参数,波 3 出口对账用显式模式。 +2. 五处宿主落位推定**确认**,以主刀实迁为准;主刀若发现推定与实际冲突,记账上报禁自行改账。 +3. type_glow_shot_probe → `game/probe/`(闸外取证探针,探针之名属实)。 +4. game/README 头部史录句改写为现路径与新体制描述,旧路径叙事不留(活文档正文口径)。 +5. test-conventions:36 载体名按合同 §三词汇对照换代。 +6. 待办清单:656 不改。 +7. 波 2 改名后把旧目录名 `game/smoke` 加入熵增闸 RETIRED 清单(防复发)。 +8. dump_battle/experiment_density 台账「一次性」件**只搬不删**。 +9. 待办清单:570 条目删除归 batch-closure,波 2 不代删。 + +## 十一、波 1 搬家代理二施工记录(G9/G10/G11/G12 —— 2026-10-22 搬家代理二) + +**作业形态=纯文本搬家**:零测试运行、零 git 提交、`game/smoke/smoke_all.gd` 与他人宿主零改动(我名下 22 个套函数与消费 helper 原文全部原地保留,删除归收尾批);manifest(run_all.gd)登记未做(归收尾装配统一登记)。六件宿主全部新建、均为未提交工作区文件——**以下各套状态=已搬运(待装配验证)**。 + +**G9 单元·元进度与锻造 → game/tests/unit/core/meta_progression_forge_test.gd(新建):六套 133 条全部迁入,已搬运(待装配验证)。** +- _meta_pricing_and_buy(4 条)/_meta_settle_and_workshop(42 条)/_meta_injection(10 条)/_smoke_forge_axes(33 条)/_smoke_tree(25 条)/_smoke_tree_rules(19 条)——各条已搬运(待装配验证)。 +- 挂载终态=宿主 `_init` 自建 `_db`(load_tables(true) 失败不中止承 G2–G5 同款);套执行序沿源(_smoke_meta 薄壳序 pricing→settle_ws→injection→forge_axes,接 _run_core_smoke 尾段 tree→tree_rules)。 +- 消费 helper(全部宿主自持私有副本):`_META_SMOKE_PATHS`(全 18 路径)+`_meta_smoke_cleanup/_meta_new_save/_fresh_rng`+`_run_battle/_auto_play_round`(**过渡副本**——去 G6 专属「收束相位回 Idle」断言,G4/G5 同款零断言纯驱动形态)+`AutoPlayer` 按现路径 `res://smoke/auto_player.gd` preload。全局 class_name 裸用:Attunement/EffectEngine/EffectContext(G3 先例)。 + +**G10 单元·存档域 → game/tests/unit/core/save_manager_domain_test.gd(新建):91 条迁入,已搬运(待装配验证)。** +- _smoke_save 60 条(原 70 条;⑨ 摘要集成块基点 :6807–6831 共 10 条按裁决三**转办集成**,宿主内留转办注记)——已搬运(待装配验证)。 +- _smoke_save_slots 31 条——已搬运(待装配验证)。 +- 消费 helper:`_fresh_rng`(自持副本)+`_db` 夹具(v2 续玩受理/收束链夹具消费);_smoke_save_slots 零 helper 自足。paths[8] `smoke_save_integration.json` 仅被转办块消费,名册照源保留(段首尾清场循环零害)。 + +**G11 单元·呈现投影与主题 → game/tests/unit/presentation/theme_projection_test.gd(新建):十套 120 条迁入,已搬运(待装配验证)。** +- _smoke_type_glow(24)/_smoke_battle_log_view(6)/_smoke_global_theme(22)/_smoke_button_five_states(23)/_smoke_texture_filter(9)/_smoke_contrast_inks(9)/_smoke_element_details(8)/_smoke_diag_button_retired(7)/_status_panel_projection(4)/_career_overlay_projection(8)——各条已搬运(待装配验证)。 +- _smoke_playtest_logger 8 条按裁决七改判集成——**不随本宿主,转办**(见下方转办清单)。 +- 挂载终态=`_db`+`_rng`(master 种子 42 沿源)双夹具;套执行序沿源(_run_core_smoke 序+_smoke_meta 薄壳尾序 status_panel→career_overlay)。 +- 消费 helper(全部自持私有副本):`_theme_rel_luminance/_theme_contrast`+微光簇全件(`_GLOW_FIXTURE_TYPES` 常量+`_glow_type_overrides/_glow_blocked` 成员+`_glow_fixture_identity/_glow_unrevealed_neighbors/_glow_expected_set/_glow_key_set_equal/_glow_map_equal`)+`_mk_stream`+`_ms_digest`+`_META_SMOKE_PATHS/_meta_smoke_cleanup/_meta_new_save`(G9 同名 helper 双份过渡,收尾批去重)+`AutoPlayer` 现路径 preload。全局类裸用:UiKit(微光五组映射);ThemeDB 单例直用。 +- **待装配验证要点**:_smoke_global_theme 依赖 ThemeDB 项目主题在座,裁决四要求实跑验证(本批禁跑测试未做)——`--script` 取不到则整体降级系统测试宿主,实跑证据记回本账本。 + +**G12 单元·源码契约+集成 → 三宿主,35 条迁入,已搬运(待装配验证)。** +- unit/data_config/rng_stream_boot_test.gd(新建):_initialize 13 条——已搬运(待装配验证)。:70「事件日志会话落盘」1 条按裁决一**转办集成**。宿主接线注记:`_initialize` 是 MainLoop 虚拟入口,宿主内改名 `_smoke_initialize` 由 `_init` 显式调用(断言零动)。 +- unit/contracts/source_guard_test.gd(新建,裁决五新设子目录):_smoke_type_gate 2+_smoke_hygiene_gates 6——已搬运(待装配验证)。零夹具零 helper(仓库文件直读面)。 +- integration/node_map_templates_test.gd(新建,首个集成新增件):_smoke_node_map_templates 14 条(裁决六整套集成不拆)——已搬运(待装配验证)。`_db` 夹具(level.rows 模板池);消费 helper:`_smoke_rest_stub`(自持私有副本——cond 内 `Callable(self, "_smoke_rest_stub")` 引用名照源)。 +- **编排壳记注**:_initialize 的编排骨架(load_ok 才进 _run_core_smoke、尾接 _split_tail)、_split_tail 瘦壳、对 _smoke_type_gate/_smoke_hygiene_gates/_smoke_node_map_templates 与我组各套的 _run_core_smoke/_smoke_meta 调用行——**编排非断言,随 smoke_all.gd 收尾整体删除退役**,不搬。 + +**对账与自检(纯文本)**: +- 六宿主 Kit.check 计数:13+8+14+133+91+120=**379**=名下总条数(390−转办 11)。 +- 标签逐字对账:六文件 379 条首参字面量与冻结清单(S01 去 14 号、S76 去 :6807–6831、S36 整章除外)脚本比对**零缺失零多出**(超出抽 5 条要求的全量比对)。 +- 粗检:六文件括号/引号平衡、无裸 `check(` 残留;git 状态=仅新增六文件,smoke_all.gd 他人文件零改动。 + +**转办清单(集成散条,宿主未指定——待主会话/装配批落位)**: +1. _initialize :70「事件日志会话落盘(2026-09-27 统一流裁决)」1 条(裁决一)。 +2. _smoke_save ⑨ 摘要集成块基点 :6807–6831 共 10 条(裁决三;消费 `res://smoke/level_autoplay.gd`)。 +3. _smoke_playtest_logger 整套 8 条(裁决七;消费 playtest_logger+真实结算流)。 + +**异常与待主会话**:① _smoke_global_theme ThemeDB 实跑验证缺位(见上,装配验证轮补);② 冻结清单处置列对上述 11 条仍写「迁单元测试」(波 0 生成早于裁决、清单禁改)——以本节转办记录为准;③ 装配验证轮须将六宿主登记进 run_all.gd manifest(含集成件 node_map_templates_test,同入口同协议)。 + +## 十二、波 1 搬家代理一施工记录(G5 剩余/G6/G7/G8 —— 2026-10-22 搬家代理一) + +**作业形态=纯文本搬家**:零测试运行、零 git 提交、`game/smoke/smoke_all.gd` 与他人宿主零改动(名下 31 个套行对应函数与消费 helper 原文全部原地保留,删除归收尾批);manifest(run_all.gd)登记未做(归收尾装配统一登记)。**以下各套状态=已搬运(待装配验证)**。 + +**G5 单元·封印与极性(剩余七套)→ game/tests/unit/battle_settlement/seal_polarity_test.gd(既有宿主 append-only 追加):67 条迁入,已搬运(待装配验证)。** +- _smoke_seal_irreversible_quadrants(21 条)/_smoke_clear_reward_chain(5 条,源内调用 _smoke_clear_reward_zero_paths 随迁)/_smoke_clear_reward_zero_paths(11 条)/_smoke_refresh_provenance_guard(5 条)/_smoke_polarity_routing(12 条)/_smoke_negative_dictionary(8 条)/_smoke_resource_surface(5 条)——各条已搬运(待装配验证)。 +- 追加形态=`_init` 套1 调用行后插六个调用行(套执行序沿源编排基点序:chain→provenance→routing→quadrants→negative→resource——provenance 依赖 chain 写入的 `_regular_clear_reward_rows` 基线)+文件尾追加七套函数与 helper;**套1 既有字节零改动**(git diff=728 insertions / 0 deletions,CRLF 沿用原文件风格)。 +- 消费 helper(宿主自持私有副本,按账本「随首用套迁入」随本批首次消费迁入):const `CLEAR_SMOKE_DEPTH/CLEAR_SMOKE_HERO_HP`+成员 `_regular_clear_reward_rows`+const `POSITIVE_EFFECTS/NEGATIVE_EFFECTS`+`_find_seal_target/_auto_play_round/_run_full_clear/_drive_clear/_rows_of_type/_has_source_row/_has_effect_id/_polarity_probe_accepts/_run_polarity_probe/_rows_at`+`_run_battle`(**过渡副本**——去 G6 专属「收束相位回 Idle」断言,G4 同款零断言纯驱动形态);AutoPlayer 复用套1 既有 preload。 +- **「他人已完成宿主」处置记账**:seal_polarity_test.gd 是账本 §五 落位计划里 G5 的预定宿主(其文件头注即写明八套),剩余七套唯一去处就是本文件——按「套1 内容勿动」执行 append-only 追加;若主会话要求另立文件请改账并移转。 + +**G6 单元·轮内流与技能池+变异 → 双宿主,134 条迁入,已搬运(待装配验证)。** +- unit/battle_settlement/round_flow_skill_pool_test.gd(新建):_smoke_full_battle(7 条标签,编排双调用 L1/L2)+_run_battle(**1 条「收束相位回 Idle」本体随迁**——G4/G5/G9 宿主零断言副本以其为本源)+_smoke_active_refresh(17)+_smoke_encounter_roll(8,含全源唯一 quiet=true 一条——Kit 通道无此参,只影响 PASS 行打印,计数/红绿同口径,标签与 cond 逐字保留并加接线注记)+_smoke_settlement_segments(5)+_smoke_skill_pool 调度壳(0 条,源内序调五件)+_smoke_skill_pool_data(27)+_smoke_skill_pool_replay_independence(2)+_smoke_skill_pool_ev_anchor(1)+_smoke_skill_pool_death_void(5)+_smoke_skill_pool_validation_reject(13)+_smoke_debuff_rider(3)+_smoke_counter_buckets(6)=95 条。套执行序沿源编排基点调用序(full_battle×2→active_refresh→encounter_roll→settlement_segments→skill_pool 壳→counter_buckets)。 +- unit/battle_settlement/variant_placement_test.gd(新建):_smoke_variant_placement(9 条标签,编排双调用 L1/L2)+_smoke_global_variant(30)=39 条。 +- 消费 helper(各自持私有副本):round_flow=`_auto_play_round/_active_refresh_replay/_row_kind_of/_boss_depth/_first_encounter_band/_level_active_nodes/_level_battle_nodes/_encounter_replay/_row_phase/_fresh_rng/_record_pool_trajectories/_has_err_category`+AutoPlayer 现路径 preload+全局类裸用 SkillLottery/SettlementTable/EffectEngine(G3 先例);variant_placement=class `_CountVariantRng`(extends RngServiceCore)+`_variant_dump/_arr_str_eq/_rows_weights_equal/_rows_weight_map/_board_resource_dump/_errs_has_cross_field`。 + +**G7 单元·冒险结构与专长 → game/tests/unit/core/adventure_feats_test.gd(新建):133 条迁入,已搬运(待装配验证)。** +- _smoke_adventure_api(11)/_smoke_adventure(54)/_smoke_feats(68=源 71−转办集成 3)——已搬运(待装配验证)。 +- 剪切记账:_smoke_feats 断点往返块(基点 :1420–1426 三条+其 save_manager 驱动代码)按裁决②整体移出宿主、宿主内留转办注记;表还原行 `_db.feats = live_rows` 原样保留(后续 V3 订阅面断言依赖现行表)。 +- 消费 helper(自持私有副本):`_smoke_find_token/_api_mark_started/_api_mark_choices/_api_mark_node/_adv_drive_to_shop/_adv_st/_battle_st/_fresh_rng/_level_active_nodes`;`res://smoke/level_autoplay.gd` 按现路径局部 load;全局类裸用 FeatTable/EffectEngine。 + +**G8 单元·关卡图与 GP 商店 → game/tests/unit/core/level_shop_economy_test.gd(新建):141 条迁入,已搬运(待装配验证)。** +- _smoke_level(11)/_smoke_level_graph(54)/_smoke_gp_wallet(9)/_smoke_gp_escrow(12)/_smoke_shop_room(22)/_smoke_shop_graph(6)/_smoke_shop_pool(27)——已搬运(待装配验证)。 +- 消费 helper(自持私有副本):`_battle_st/_fresh_rng/_escrow_sum_of/_find_gp_dig/_shop_products_by_id/_adv_drive_to_shop/_adv_st/_all_entry_boss_paths/_level_active_nodes/_first_encounter_band/_boss_depth/_has_err_category`;`res://smoke/level_autoplay.gd` 按现路径局部 load。 + +**对账与自检(纯文本)**: +- 宿主 Kit.check 计数:seal 追加 67(+套1 既有 5=文件共 72)+round_flow 95+variant_placement 39+adventure_feats 133+level_shop_economy 141=**475**=名下总条数(478−转办 3)。 +- 标签/cond 全量脚本比对(超出抽 5 条要求):五宿主 475 条按被迁函数逐条与 smoke_all.gd 源对齐(label/cond 折叠口径零差异;_smoke_feats 按剪切后 68 vs 源 71 对齐);475 条标签字面量与冻结清单各套章节逐字比对零缺失零多出。 +- 粗检:四新建文件无裸 `check(` 残留(round_flow 仅一处源注释「禁恒真 check(true)」随套逐字带入);文件尾换行齐。 + +**转办清单(集成散条,单元宿主不收——待装配批落位)**: +1. _smoke_feats 基点 :1420–1426 三条(裁决②):「断点附随件:relics 持有集入快照(迭代 12 T1 扩列)」「断点附随件:adventure 计数器账本键在案(T6/T7 扩列)」「断点余库:已兑付 relic 令牌逐枚还原(x3 零残留——续玩不重复获得)」——驱动代码=save_manager 临时档(`user://_smoke_feats_bp.json`)装配+注入表上 `_on_level_finished(victory)`+`breakpoint_snapshot()` 取读+`clear_breakpoint()`,随三条同迁(adventure_feats_test.gd 内已留注记)。 + +**异常与待主会话**:① 「他人已完成宿主一个字不许改」与「G5 剩余套落位 seal_polarity_test.gd」字面冲突,按 append-only 执行(见上 G5 节),请主会话追认或改判;② 冻结清单处置列对 feats 三条仍写「迁单元测试」(波 0 生成早于裁决、清单禁改)——以本节转办记录为准;③ 我批被迁函数与共享 helper 在 smoke_all.gd 原地保留(本波禁改 smoke_all),其中 `_find_seal_target/_run_full_clear/_drive_clear/_rows_of_type/_has_source_row/_has_effect_id/_run_polarity_probe/_polarity_probe_accepts/_rows_at` 与两 const 的 smoke 侧消费者已全部迁毕,原件删除归收尾批;④ 跨宿主重复 helper(收尾去重候选):`_fresh_rng`×3(rf/af/ls)+`_has_err_category`×2(rf/ls)+`_level_active_nodes`×3(rf/af/ls)+`_first_encounter_band`×2(rf/ls)+`_boss_depth`×2(rf/ls)+`_battle_st`×2(af/ls)+`_adv_st`×2(af/ls)+`_adv_drive_to_shop`×2(af/ls)+`_auto_play_round/_run_battle` 过渡副本多宿主(rf 主持本体带断言,G4/G5/G9 副本零断言);⑤ 装配验证轮须将四件新宿主登记进 run_all.gd manifest。 + +## 十三、装配批施工记录(2026-10-22 装配代理) + +**作业形态=纯文本与 python 级装配**:零游戏测试(引擎由验证线并行占用)、零 git 提交、M1/M2 十一件已迁宿主内容零改动(seal_polarity_test.gd diff 终检仍=728 insertions/0 deletions,即 M1 交付原态)。以下装配结果=待统一验证,实跑归主会话。 + +**1. 转办 22 条落集成宿主**:新建 `game/tests/integration/cross_system_flow_test.gd`(命名照 integration 现有惯例;与单元同入口同协议=extends SceneTree+Kit.check 主通道;表加载失败不中止承 G2–G5 同款;驱动随条自持、零新增断言——前置受理失败自然落后续断言红)。四块去向(四宿主注记所指转办义务至此全部落位): + +| 转办块 | 条数·裁决 | 宿主函数 | 驱动形态 | +|---|---|---|---| +| _initialize :70 事件日志会话落盘 | 1·裁决一 | `_rng_event_log_session` | 沿源取号序列自持:set_master_seed(42)→open_stream×2→new_adventure(4242)→open_stream("gp_drop")→查 `user://logs/rng_log_4242.jsonl` | +| _smoke_feats 基点 :1420–1426 断点往返 | 3·裁决二 | `_feats_breakpoint_roundtrip` | 沿源 :721–797 重建:5 行临时表注入+seed 72 起局+兑付 x3+save_manager 临时档(`user://_smoke_feats_bp.json`)装配+_on_level_finished(victory)+快照取读+clear_breakpoint;起局/兑付两步受理断言留单元宿主;临时档段首清场+段尾清理;表还原行保留 | +| _smoke_save ⑨ 摘要集成块 基点 :6807–6831 | 10·裁决三 | `_save_summary_integration` | level_autoplay 按现路径 preload;`user://smoke_save_integration.json`(源 paths[8])段首清场+段尾清理,绝不碰 user://save.json | +| _smoke_playtest_logger 整套 | 8·裁决七 | `_smoke_playtest_logger`(函数名沿源) | PlaytestLogger×BattleOrchestrator 真实结算流驱动照源(快照信号收集/停手两结算/诊断转储/JSONL 回读/close_session) | + +**2. manifest 登记**:`game/tests/run_all.gd` `_suites()` 追加 11 行(44→55 条,expect 全 0),格式照现行惯例(域分组插入+注释枚举覆盖面+「批 B 自 smoke_all 迁入」尾注):unit 九件新=round_flow_skill_pool/variant_placement/adventure_feats/level_shop_economy/meta_progression_forge/save_manager_domain/theme_projection/rng_stream_boot/source_guard;integration 两件=node_map_templates/cross_system_flow(转办块宿主)。既有四件登记(table_structure_guard/board_gen_reward/roll_multiplier/seal_polarity)未重。 + +**3. 闸 01 切换**:`tools/run_system_test.py` DEFAULT_SCRIPT 改 `res://tests/system_test.gd`(头注宿主句与用法注释同步;闸 01 识别的两形态摘要行不变);`tools/run_all_gates.sh` 闸 01 注释改指新宿主,闸键名「01 系统测试」未动。 + +**4. 删源**:`game/smoke/smoke_all.gd`+`smoke_all.gd.uid` 整体删除(两件均入 git 历史,工作区 ` D` 未暂存,与两搬家代理未提交产出同批待主会话统一提交)。 + +**5. extract_checks.py 改造与自检**:默认源改三宿主遍历(unit/**+integration/** 递归+system_test.gd,现命中 58 文件);`--src` 升级多文件显式模式(相对路径按仓库根解析);解析器双通道=点前缀 `Kit.check(` 标签在前/裸 `check(` cond 在前(`_check(` 同名前缀与标签实参无字面量的 wrapper 调用不取——先例 reward_overlay_test `_req` 直传变量;字面量损坏仍会以「消失」呈现,不漏检);`--extract`/`--list-funcs` 随多源升级(多源按〔源文件,函数〕分章,章头带源);冻结清单防误覆不变。对账指纹按合同 §五三要素(标签/cond/计数)修正——quiet 列保留展示不入指纹:quiet 是 smoke 通道打印档位(只影响 PASS 行打印,计数/红绿同口径,M1 已记账),Kit 通道无此参,波 0 四元指纹在通道转换后结构性失配;清单冻结三列未动一字,非放宽对账(quiet 变更在 smoke 通道已无承载面)。 + +自检两口径(python 级,非游戏测试): +- **显式模式(16 宿主=本批迁移面全量):清单 1065/现提取 1065 计数对齐,标签/cond 逐字零差异,PASS(退出码 0)**——1043 已搬+22 转办全在宿主。分件计数与 §二/§五落位逐件相等:system_test 49/table_structure_guard 60/rng_stream_boot 13/source_guard 8/board_gen_reward 39/adventure_feats 133/level_shop_economy 141/meta_progression_forge 133/save_manager_domain 91/roll_multiplier 36/seal_polarity 72/round_flow_skill_pool 95/variant_placement 39/theme_projection 120/node_map_templates 14/cross_system_flow 22。 +- **缺省遍历模式:消失 0/标签变更 0/cond 变更 0;新增 317 条全部来自既有套件**(config_db_test 34、damage_controller_test 34、type_glow_test 23、level_map_type_vis_test 23、damage_math_test 21、forge_table_test 20 等 22 文件——全为批前在册单元/集成套件的 Kit.check 断言,非本批迁移面;16 宿主在新增列零出现)。退出码 1 系「新增」类差异,属遍历面含既有套件的预期形态;批 B 出口判定以显式模式为准(§十.1「波 3 出口对账用显式模式」同口径)。 + +**待实跑(归下一棒验证代理/统一验证)**: +- 裁决四 `_smoke_global_theme` ThemeDB 项目主题 `--script` 取得性验证(theme_projection_test 宿主内注记同此)——取不到则整体降级系统测试宿主,实跑证据记回账本。 +- 全部宿主与闸 01/02 实跑归统一验证(本批零游戏测试);manifest 新 11 件 expect 全 0(无致命/负面类)。 +- 工具与文档面遗留(§七普查在案,归波 2):`game/tests/static_scan.sh:100` S11 头注仍提 smoke_all(纯注释);`game/tests/README.md` 过渡期口径句(含 manifest 条数句现为 55)、AGENTS.md:20、architecture.md、game/README 等引用面同批换代;manifest 登记的 11 件新宿主 .uid 边车由引擎首轮导入自动生成(与 M1/M2 九件新件同态,禁手造)。 + +## 十四、核代码修复批施工记录(2026-10-23 主会话执行) + +三冷启动审核员 1065 条全审:1047 有效 / 17 条逐条存疑 + 2 段落级(后者经今日闸记录实证解决:system_test.log 65 项 0 失败=48 条 autoload 门控断言全实跑;unit.log「V1 项目主题挂载」PASS=ThemeDB 在座 21 条全实跑)。grilling 审查一轮:5 条方案成立、4 条被击穿修订(A1 收敛为 has 键在案/B7 改夹具观测路线并补 hp_acc 项/C9 落锤现态加场景绑定/B8-区间行改判测试侧死逻辑——「生产疑似 bug」系 G3 错账已撤销)、另按升格标准新增 attach/rider 键名修复。10 条全部由主会话执行完毕: + +1. cross_system_flow:92 恒真→`bp_r.has("adventure_counters")`(键在案语义;该流零计数器内容,禁验 size>0) +2. table_guard:493 前置 derive_at×3 调用,使命题「纯函数不推计数器」真正被验 +3. table_guard:130 拆两条:null 早退分支(label 补注)+ _TreeSaveMock 非 null 档真实计数(socket1=1) +4. theme:554/580/596 对象引用比较→内容比较(新增 `_sb_content_differs` 助手:Texture 比纹理/Flat 比 bg_color/异型即不同) +5. table_guard 护栏后新增绊线钉:hero_defense 池映射数==0 +6. seal:481-488 补 ap_delta 按行值符号辨极性分支(与安全格循环同口径;_polarity_probe_accepts 未动;:537 同形缺口靠受理谓词保护的依赖已记) +7. level_autoplay 停点加 bonus_max_hp 观测面(测试基建)+ adventure_feats:185-210 窗口拟合改观测值精确断言(公式含 hp_acc 项) +8. board_gen:270-275 自比较死逻辑重写:新增 draws 总抽数计数,判据改「总抽≥2 且不同值<2」 +9. level_shop:806-818 白盒抬 GP+64 绑定场景+else 显式红护栏 +10. roll_multiplier:443 "attach"→"rider" 键名修复 + +**闭案措辞(按测试规约 §6)**:system_test :279/:421 与幂等等值类=「结构复核通过、变异拦截能力未证(执行前提/预算收刀)」。**变异形态留档(只登记不执行——测试已被用户叫停)**:1=adventure_runner.gd:1025 键改名|2=derive_at 内推计数器|3b=计数循环恒 0|4=disabled 改挂 pressed 同内容|5 钉=effect_pool_map 加 hero_defense 映射|6=seal 兑付行符号翻转|7=回血基数改表值上限|8=双掷点恒取下界(R1 已实证形态)|9=货架全不可负担|10=Boss 池加入 rider_mult 技能。**错账撤销**:G3「生产疑似 bug 登记待办」作废——区间行死逻辑在测试源码自身,本批已修。 diff --git a/reports/提案-测试术语与层级重构方案-2026-10-22.md b/reports/提案-测试术语与层级重构方案-2026-10-22.md new file mode 100644 index 00000000..9ec5fb8f --- /dev/null +++ b/reports/提案-测试术语与层级重构方案-2026-10-22.md @@ -0,0 +1,115 @@ +# 测试术语与层级重构方案(v2) + +> 状态:提案 v2 · 2026-10-22 · 独立审查 12 条修正已吸收,用户裁决全部闭合,**待裁点清零**,待用户安排批 A 开工 +> 生命周期:本文件的批计划部分批末退役(吸收即死);§五验证集清单是常驻参照物,不随批死 +> v1→v2 沿革查 git log,正文不设变更记录节 + +## 一、背景与问题(证据附行号,计数标口径与日期) + +**① 名实分离**:`game/tests/README.md:137–148` 术语表定义冒烟=秒级存活检查,但名叫 smoke 的载体 `tools/run_smoke.py` 跑的是 `res://smoke/smoke_all.gd`——7151 行、140 函数;检查计数:**运行时 1211**(2026-10-22 跑批读数)/**静态 check( 调用点 1067**(2026-10-22 实测),单次 90–110 秒。断言内容举例:随机数流具体数值(`derive_at(0)==8672514112661456564`,:53–60)、格子尺寸公式(:1691)、奖励恒等式(`_smoke_reward_identity_unit`,:1977)、封印耗尽行动点进第 2 轮(:2027)。README:146–148 靠一段警告维持名实分离——需要警告才能防误读的命名,就是失败的命名。 + +**② 频率三处冲突**:迭代技能写每轮跑 `run_smoke.py`+`probe_gate.sh`;`roadmap.md` 批次 loop 规范第 4 条写每轮跑 `--quick`;`process-conventions.md` §3 第 5 条写开工三查。而 `--quick` 内含 01(冒烟全量)与 02(单元)两道分钟级闸——「每轮轻闸」实际是每轮全量。一次 2 卡×3 轮迭代要跑 6–8 次分钟级套件。 + +**③ 金字塔倒挂**:标准层级应单元最多、系统测试少。现状:单元测试 40 条 manifest(39 单元+1 集成;检查数:运行时 367/静态 322)对 smoke_all 静态 1067 点;本该承接跨系统用例的 `tests/integration/` 仅 1 件。成因:`run_smoke.py` 是历届每轮必跑的闸,断言全往里堆;`unit/` 成型晚,早期断言未搬。证据在函数名里:`_smoke_reward_identity_unit`、`_smoke_layout_cell_formula`、`_smoke_adventure_table`。 + +**④ 词汇自造**:讨论中出现「系统级断言套件」「单元套件」「启动探针」「L0–L3」「轻闸/重闸」等自造词。 + +## 二、裁决记录(2026-10-22,全部闭合) + +| 事项 | 裁决 | +|---|---| +| 早三裁 | ①系统级断言面只在批末质量门跑一次;②新建专用冒烟测试实体;③正名到文档+工具文件名+日志行 | +| Q1 | 检出工作区零测试,三查整条删除——master 经合回闸保证可用 | +| Q2 | 实体标准名=**冒烟测试**,不造第二词 | +| Q3 | 卡内只测自己改的东西;全量只有批末+合回两个时机 | +| Q4 | smoke_all.gd 按层级重构拆解。**覆盖裁决③中「smoke_all.gd 与 game/smoke/ 保留旧名」一条** | +| Q5 | 命名用标准工程用语,定名**系统测试**;废弃一切自造词 | +| 批 A 批类 | 非迭代批(规则与闸件批,用户开工定性);用户直接下开工令,跑全量域闸 | +| 批 B 载体 | **用户特批:免写需求卡片,直改 game/**(证据义务保留:清单+按条变异+全量域闸) | +| 变异粒度 | **按条**(用户知悉约 4–6 工作日代价后裁定,不许一点纰漏)。现行 test-conventions §6 本就是断言级条文,原样适用,无需修文 | +| 软项确认 | 冒烟形态=极小启动场景;测量件=`game/sim/`;系统宿主=`game/tests/system_test.gd` | +| 验证集清单 | 采纳:机器生成、冻结参照、只剪切禁改写、出口逐条 diff、常驻不随批死 | + +## 三、词汇修正对照 + +| 处置 | 词 | 依据 | +|---|---|---| +| 保留 | 冒烟测试(smoke test) | Wikipedia/微软构建验证测试口径 | +| 保留 | 单元测试、集成测试、系统测试、验收测试 | 软件工程标准测试层级(ISTQB 及通用教材) | +| 保留 | 回归测试、质量门(gate)、探针(probe)、golden 基线 | CI 通用;k8s liveness/readiness probe;golden master | +| 废弃→系统测试 | 系统级断言套件、冒烟全量 | 自造词 | +| 废弃→单元测试 | 单元套件 | 「套件」单独成词非必要 | +| 废弃→冒烟测试 | 启动探针、秒级冒烟探针、L0–L3 | 自造词 | +| 废弃→消灭 | 轻闸、重闸、三查 | 随新频率规则失去指称对象;消亡口径=**活文档正文消失**(reports/ 历史件留痕不算) | + +**机检承接**:批 A 把上表废弃词登记进 `design/gdd/术语表.md` 退役词对照,08 闸自动拦截复发。 + +## 四、目标架构 + +| 层级 | 载体 | 职责 | 规模 | 单次耗时 | +|---|---|---|---|---| +| 冒烟测试 | `tools/run_smoke.py`(重写)+ `game/tests/smoke_boot.tscn/.gd`(极小启动场景,以项目运行目标拉起=真实开机路径:autoload 注册、配置表加载、主屏实例化;断言后 quit(0/1) 出退出码) | 这版活着吗,零逻辑判定 | 新建 10–20 项 | 10–15 秒 | +| 单元测试 | `game/tests/unit/`(现役不动)+ 迁入 | 函数/公式/状态机,隔离断言 | 现役静态 322 点+迁入 | 76 秒 | +| 集成测试 | `game/tests/integration/`(现役 1 件)+ 迁入 | 跨系统:存档⇄RNG 往返、回放确定性 | 现役 1 件+迁入 | 秒级 | +| 系统测试 | `tools/run_system_test.py` + `game/tests/system_test.gd`(SceneTree+构造注入机制保留,迁移期宿主行为零变,红绿归因不混噪声) | 启动真实工程走真实玩法路径 | smoke_all 拆余部分 | 拆后变短 | +| UI 探针+渲染 golden | `probe_gate.sh`、`render_gate.sh`(名含标准词,不动;批 B 随目录改名改路径) | 真实 UI 走查(含模拟点击)、画面基线 | 14 个探针场景 | 现状不变 | + +命名决定:`run_smoke.py` 名改配新冒烟(名字与内容终于一致);`smoke_all.gd` 拆空后删除;`game/smoke/` 拆分——探针场景与宿主 → `game/probe/`,测量诊断件 → `game/sim/`。 + +## 五、验证集清单机制(四重保证) + +**验证集**=迁移对象全体断言(smoke_all.gd 全部);**清单**=其登记文书 `game/tests/验证集清单.md`,**常驻参照物**。 + +1. **结构**:套=章节(源函数、行段、去向);条=行——标签原文、白话释义、处置列(每条各自的去向/处理)。 +2. **生成**:`tools/extract_checks.py` 机器提取(静态调用点口径;运行时与静态差=循环展开条目,在册标注),禁手写。批 B 第 0 步生成并冻结(基点 commit 在案)。 +3. **搬运纪律**:只剪切禁改写——标签与条件式逐字原样搬;任何改写=新断言改动,走单独流程另配变异证明。 +4. **出口机检**:迁移后再提取,与清单逐条 diff——标签/条件式/计数三对齐才算完。 +5. **活性证明**:按条变异(§七批 B)。 +6. **维护义务**:此后断言新增/删除/改判必须同步清单——批 A 在 test-conventions §9 加一句锚,挂现有改动—验收对照表纪律。 +7. **范围**:本批只收录迁移对象;unit/integration 现役断言的收录列为后续可选项,不在本批承诺。 + +四重保证各管一段:①冻结参照(清单)→保真有据;②只剪切禁改写→语义漂移在源头杜绝;③出口逐条 diff→漏搬/重搬/字面变更;④按条变异→搬过去哑了的断言。前三重保证「不缩小不改变」,第四重保证「还活着」。 + +## 六、频率规则(三条,无例外) + +1. **检出工作区:零测试**。建 worktree、拷素材与导入缓存、git status 归属核对,直接开工。 +2. **卡内:只测自己改的东西**。本卡新增/改动的验收测试自己跑绿即收口;没改的不测。既有「验收测试随卡落地」「调试期只重跑红件最小单元」保留。 +3. **全量只有批末+合回两个时机**,跑闸机按域选档,回执机制不变。 + +## 七、执行方案(一个设计,两批落地,批 B 依赖批 A) + +### 批 A:命名+频率+新冒烟测试(非迭代批;估 0.5–1 天;顺序=先立规则,再照规则施工,最后验证收口) + +1. **登记**:待办清单立条目(第一动作)。 +2. **改规则**(先立约):`process-conventions.md`——删三查条、§3 重编号+全仓「§3.N」式指针核对(AGENTS.md:19 在案)、§3.4 环境准备保留、§9 加验证集清单维护锚;`roadmap.md` loop 规范第 2/3/4 条重写(T0 零测试、轮内无闸、批末+合回全量);`design/gdd/术语表.md` 废弃词登记退役词对照;`AGENTS.md` 规则行(单件直跑行、验收行、运行与验证小节路径更新)。 +3. **写测试代码**(照约施工):新建 `game/tests/smoke_boot.tscn/.gd`(极小启动场景);重写 `tools/run_smoke.py`(跑新冒烟);新建 `tools/run_system_test.py`(过渡期指向 smoke_all.gd)+`game/tests/system_test.gd`(SceneTree+构造注入)。 +4. **改闸机**:`run_all_gates.sh` 闸键映射——01 冒烟全量→01 系统测试、新增 00 冒烟测试(排最前)、02 单元套件→02 单元测试,其余 12 键不动;`baseline.json` 同步改键,**重生成前先单跑 09 静态扫描核实现绿**(基线记 exit=1 已滞后,2026-10-22 16:20 回执为 exit=0);届时若红→停、查、禁带红重生成。实现期核对:`tests/run_all.gd` 对 integration/ 的扫描归属→闸 02 定名(单元测试,或单元与集成测试)。 +5. **改描述性文档**:`game/tests/README.md` 术语表改标准四级、删「别被字面骗了」警告节(名字对了警告就死)、订正滞后读数(挥发读数能删则删,必留标口径+取数日);`architecture.md` 测试架构节+:11 文档头实证行;「轻闸/重闸」词面清扫(`体验验证清单.md`、`待办清单.md`、`tools/probe_gate.sh` 注释、`tools/run_all_gates.sh` 注释 :2/:119)。 +6. **技能三副本**:库外现行 `C:/Users/DK/.workbuddy/skills/iter/SKILL.md`+库内 `.zcode`、`.qoder` 改写并保持逐字一致(实测 `.qoder-cn` 无副本);改技能重启会话生效。 +7. **验证**:全量域 `tools/run_all_gates.sh`(不带参数)跑绿;新冒烟变异验证(改坏启动→红→还原→复绿);闸机改动按 §6 变异验证。 + +### 批 B:断言迁移(用户特批免卡直改 game/;估 1.5–2 周) + +1. 生成并冻结验证集清单:`tools/extract_checks.py` 机器提取(基点 commit 在案);`smoke_all.gd` 冻结,批 B 期间不接受任何新增断言。 +2. 逐套循环约 40 次:搬一套(只剪切禁改写)→ 新宿主实跑该套 → 套内按条变异(全库约 1067 静态点;每轮=改坏该断言守的生产代码→跑该断言→验红→`git checkout` 还原→`git status` 清洁确认→账本留痕;每约 200 轮抽点自查防疲劳失误)→ 原子提交(新宿主落函数+源文件同 commit 删函数)。迁移本体 2–4 天+变异 4–6 工作日。 +3. 出口对账:再提取与清单逐条 diff(标签/条件式/计数三对齐)。 +4. 引用清零:全仓 grep `game/smoke`、`res://smoke`、`smoke_all`(reports/ 历史件除外);在案必改点——`run_all_gates.sh:206` PARITY_TOUCH_FACES(11 号闸触面判定逻辑,非注释)、`AGENTS.md` 在册入口、`tools/parity_anchor.py`、`tools/balance_verifier.py`、`sim-data/analyze.py`、`architecture.md`、`design/gdd/术语表.md` 豁免行、08d 闸在册行号指针重核。 +5. 目录收尾(1–2 天):删 `smoke_all.gd`;探针场景与宿主 `game/smoke/`→`game/probe/`;测量诊断件→`game/sim/`;熵增闸白名单+Project Layout 同批修订(硬性红线现成要求)。 +6. 终验:全量域闸绿+金字塔摆正(单元静态检查数>系统测试静态检查数);四重保证记录齐全。 + +## 八、时间汇总 + +批 A 0.5–1 天;批 B 约 1.5–2 周(迁移 2–4 天+按条变异 4–6 天+收尾 1–2 天,串行)。两批之间停机等用户发令。 + +## 九、执行注意事项 + +- 两批均走独立 worktree(本分支 `test-term-reform` 可续用);主区禁直改直提。 +- 串行跑闸:`user://save.json` 全 worktree 共用,并行必假红。 +- 测试执行依据=用户点名(两入口铁规第二入口);MC 零跑(无数值改动)。 +- 跨会话记忆文件已随本 v2 更新。 + +## 十、微决策已定项(可否决) + +00 键名「冒烟测试」排最前;冒烟场景落 `game/tests/smoke_boot.tscn`;清单文件名 `game/tests/验证集清单.md`;提取脚本 `tools/extract_checks.py`;清单不收录 unit/integration 现役断言(后续可选)。 + +**待裁点:无——全部闭合。** diff --git a/roadmap.md b/roadmap.md index 4c78a3d8..969ee8af 100644 --- a/roadmap.md +++ b/roadmap.md @@ -77,7 +77,7 @@ ## 执行纪律(2026-09-27 用户立规) 1. **一迭代一 loop**:所有更改任务(功能/文档/数值/素材接线)一律进本路线图的迭代,每迭代先立计划书再开工,一个迭代一次合并登记——**零散并行会话退场**(多会话并行 = 冲突源+介入成本;2026-09-27 起单迭代串行执行)。状态记录记「批」= 本文件记「迭代」,同物异名。 -2. **例外 = 零碎 bug**:单独会话 mini-loop 修复(修→轻闸→状态记录登记),不立计划书、不占迭代位;同屏同类 bug 攒够一笔可并入下一个迭代当任务卡。 +2. **例外 = 零碎 bug**:单独会话 mini-loop 修复(修→只测自己改的东西→状态记录登记),不立计划书、不占迭代位;同屏同类 bug 攒够一笔可并入下一个迭代当任务卡。 3. **用户输入闸显式化**:迭代门里所有需要用户表态的点(否决原因/裁决/试玩)登记在 [待办清单](待办清单.md) 的状态列(待裁决/待美术设计/待写卡),解一个开一个。 ## 批次 loop 规范(loop engineering) @@ -87,10 +87,10 @@ > **迭代粒度界定(对齐敏捷迭代/Sprint)**:一个迭代 = 一句迭代目标 + **3–8 个更改任务** + 一份计划书 loop + 一次合并登记,交付一个可验证增量(≈一次迭代交付)。单交付物不成迭代——并入相邻迭代当任务卡;用户输入闸未解的任务不进迭代(留待办清单当 backlog,解闸后编入下一个迭代);**迭代周期**:常规 5–10 个工作日(1–2 周)、直接编;超过 10 个工作日(超过 2 周)须经用户确认;低于 5 或超过 20 个工作日不得编(硬下限/硬上限,2026-10-21 用户裁定;列形态与闸判据见 doc-conventions §12.2 与流水线闸)。**里程碑 = 2–4 个迭代的发布级目标 + 出口验收闸(≈Release)**。 > 1. **计划书**(`.zcode/plans/<迭代名>-loop-plan-<日期>.md`):迭代目标与完成定义 / 现状事实(代码级,开工自行复核)/ 设计契约(违反即返工,标 🔴 触碰=L3 停机)/ 范围边界 / 任务卡 T0..Tn(逐卡估时)/ 验证闸 / 异常分级 / 代拍板白名单。 -2. **T0 开工**:建 worktree + 基线复读记档(冒烟/探针/三闸/MC 与基线数据逐项比对基准)+ `git status` 归属核对(并行遗留只登记不触碰)。 -3. **循环骨架**:一轮 = 一个任务卡——读卡 → 对事实源 → 实装 → 轻闸全绿 → 提交(代拍板随 commit 记录)→ 状态记录追加(查撞号)→ 下一轮。单 worktree 多点提交一次合并;轮内禁开第二个 worktree;基线每轮开工时点重测;断点续跑 = `git log` 对任务卡定轮次 + 重跑当轮轻闸。 -4. **轻闸**(每轮必跑,headless):`tools/run_all_gates.sh --quick`(迭代中段自检档)。 -5. **重闸**(批末 Tn):`tools/run_all_gates.sh` **按本批改动域跑**(触及 `game/` → `--code`,这是代码域唯一触发;不触及 `game/` 的一切路径 → `--docs`;一次改动同批混触两域 → 不带参数全量),所选域全绿——闸件清单、顺序与域划分只在该脚本里定义,本文件与 AGENTS.md/规约均不另列;改闸件自身(`tools/` 下机检脚本与闸壳)不占合回全量,质量义务=落批时的变异验证;涉核心的 MC 与基线逐位比对、素材批的机检断言另行。 +2. **T0 开工**:建 worktree + 拷素材与导入缓存(冷检出三件套照坑速查)+ `git status` 归属核对(并行遗留只登记不触碰)——**检出工作区零测试,直接开工**(2026-10-22 频率规则;master 经合回闸保证可用)。 +3. **循环骨架**:一轮 = 一个任务卡——读卡 → 对事实源 → 实装(验收测试随对应 T 卡同轮落地;**本卡新增/改动的验收测试自己跑绿即收口,没改的不测**)→ 提交(代拍板随 commit 记录)→ 状态记录追加(查撞号)→ 下一轮。单 worktree 多点提交一次合并;轮内禁开第二个 worktree;轮内不跑闸;断点续跑 = `git log` 对任务卡定轮次。 +4. **全量闸时机(2026-10-22 频率规则,无例外)**:全量只有批末+合回两个时机,轮内零跑闸(含 `--quick` 中段自检档)——卡内只测自己改的东西;既有「验收测试随卡落地」「调试期出红只重跑红件最小单元」两条保留(闸机轮次纪律见 process-conventions §6)。 +5. **全量域闸**(批末 Tn):`tools/run_all_gates.sh` **按本批改动域跑**(触及 `game/` → `--code`,这是代码域唯一触发;不触及 `game/` 的一切路径 → `--docs`;一次改动同批混触两域 → 不带参数全量),所选域全绿——闸件清单、顺序与域划分只在该脚本里定义,本文件与 AGENTS.md/规约均不另列;改闸件自身(`tools/` 下机检脚本与闸壳)不占合回全量,质量义务=落批时的变异验证;涉核心的 MC 与基线逐位比对、素材批的机检断言另行。 6. **人闸**(停机交付):真人目检清单 + 代拍板追认清单 + 异步终审触点(样张类)——本循环内唯一必经人审。 7. **异常分级**:L1 自救(一次重试)→ L2 记录继续(连续 3 次同红灯升级)→ L3 停机写报告等用户。 8. **批次完成后的五步登记**:①待办清单条目状态按 doc-conventions §12.1 推进(做完整条删除)②本文件顶部「当前位」三行换代,涉及里程碑移动时同步里程碑表 ③**实现批禁写本文件与 `design/`**(代码实现批只准写 `reports/`),该改哪一行写进 `reports/iter-反哺点-<日期>.md`,由文档批执行 ④阶段变更改 stage.txt ⑤批末闸序列**按本批改动域**全绿留痕(回执 commit 对齐分支尖)。 diff --git a/tools/doc_term_audit.py b/tools/doc_term_audit.py index b6b4688e..5bfeab55 100644 --- a/tools/doc_term_audit.py +++ b/tools/doc_term_audit.py @@ -69,6 +69,13 @@ RETIRED_TERMS = [ ("金币", "2026-10-18", "GP(唯一正式命名)", "GP 命名统一批——「金币」全库禁用"), ("插旗|标旗", "2026-10-18", "封印(正式动作名,全库统一)", "术语刷新批——「旗」概念退役"), ("完美排雷", "2026-10-19", "完美封印(正式名见 design/gdd/术语表.md 退役词对照)", "迭代 32 卡①反哺批 2026-10-21 用户裁入表——代码注释面由 S11 承接,文档正文面由此条承接"), + # —— 测试术语与层级重构批(2026-10-22 用户裁):权威=design/gdd/术语表.md「退役词对照」—— + # 标准四级测试层(冒烟/单元/集成/系统)+UI 探针与 golden;闸档用语随三条频率规则消亡。 + # 「重闸」用负向后行断言排除机制词「去重闸」(封印重判去重判别器,在役非退役)。 + ("系统级断言套件|冒烟全量", "2026-10-22", "系统测试", "测试术语重构批——标准四级层命名(裁决录=reports/提案-测试术语与层级重构方案-2026-10-22.md §二)"), + ("单元套件", "2026-10-22", "单元测试", "测试术语重构批——标准四级层命名"), + ("启动探针|秒级冒烟探针|L0[–-]L3", "2026-10-22", "冒烟测试", "测试术语重构批——极小启动场景"), + (r"(?...] [--out <清单文件>] [--force] + 生成验证集清单(已存在时拒写,除非 --force——冻结参照物防误覆) + python tools/extract_checks.py --diff <清单文件> [--src <源文件>...] + 对当前源码再提取,与清单逐条比对——批 B 出口对账唯一工具 + python tools/extract_checks.py --list-funcs [--src <源文件>...] + 只打印函数清单与各函数条数(编账本辅助,不写文件) + +断言源(--src 可多个;缺省=三宿主遍历): + game/tests/unit/**/*.gd + game/tests/integration/**/*.gd + game/tests/system_test.gd + 批 B 出口对账用显式模式(--src 列全本批宿主文件)——缺省遍历含既有套件的 Kit.check + 断言,对账时以「新增」出现(非迁移面),出口判定以显式模式为准。 + +提取口径: + - 静态 check( 调用点,两种通道都认:smoke/系统宿主的 check(cond, label)(cond 在前) + 与单元/集成宿主的 Kit.check("label", cond)(点前缀=标签在前);`_check(` 等同名 + 前缀调用非标准通道,不取。 + - 调用点可跨行:从 `check(` 起括号配平归集完整实参文本(() [] {} 三种括号都计深度); + 字符串字面量内的括号/逗号/井号、行尾注释、转义序列均不参与结构判定(码位掩码单趟预扫)。 + - cond 摘要=cond 原文跨行折叠为单行(连续空白并一空格);标签原文=标签实参里的字符串 + 字面量逐字保真(含引号与转义序列原样,禁改写);标签实参若带格式化/拼接后缀 + (`"..." % [...]`),后缀折叠后以「↩」接排在标签列。 + - md 表格单元格内 `|` 转义为 `\|`、`\` 转义为 `\\`(源码串内无这两序列的歧义形态,读回可还原)。 + +--diff 对账规则:只比对「标签+cond+计数」三要素(合同 §五出口机检);行号随迁移漂移、 +序号重排、白话释义/处置列由维护回填,均不参与比对。对不上的条目分三类报告: +消失(清单有现码无)、新增(现码有清单无)、成对字面变更(cond 同标签异=标签变更; +标签同 cond 异=cond 变更——一条「消失」与一条「新增」同因一字之改时按成对呈现)。 +退出码:0=全对齐;1=有差异;2=用法错或文件不可读。 +""" +import argparse +import bisect +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +# 断言源缺省=三宿主遍历:unit/**+integration/** 两目录递归 + 系统宿主单文件。 +# 遍历面含既有套件的 Kit.check 断言(非本批迁移面,对账呈「新增」)——出口对账用 --src 显式列宿主。 +DEFAULT_TREES = ( + ROOT / "game" / "tests" / "unit", + ROOT / "game" / "tests" / "integration", + ROOT / "game" / "tests" / "system_test.gd", +) +DEFAULT_OUT = ROOT / "game" / "tests" / "验证集清单.md" + + +def default_sources(): + """三宿主遍历:两目录递归 *.gd + 系统宿主单文件,路径排序定序。""" + files = [] + for p in DEFAULT_TREES: + if p.is_file(): + files.append(p) + elif p.is_dir(): + files.extend(sorted(p.rglob("*.gd"))) + return sorted(files) + +CALL_RE = re.compile(r"check\(") +DEF_RE = re.compile(r"^\s*(static\s+)?func\s+check\s*\(") +FUNC_RE = re.compile(r"^(static\s+)?func\s+([A-Za-z_][A-Za-z0-9_]*)") +LITERAL_RE = re.compile(r'"((?:[^"\\]|\\.)*)"') +OPENERS = "([{" +CLOSERS = ")]}" +CLOSER_OF = {")": "(", "]": "[", "}": "{"} + + +def fold(text: str) -> str: + """跨行折叠:连续空白(含换行/制表/回车)并为一空格。对账归一口径。""" + return re.sub(r"\s+", " ", text).strip() + + +def esc_cell(text: str) -> str: + return text.replace("\\", "\\\\").replace("|", "\\|") + + +def unesc_cell(text: str) -> str: + """esc_cell 的逆。源码串内无 `\\|`/`\\\\` 歧义形态(提取口径节),还原无歧义。""" + out = [] + i = 0 + while i < len(text): + if text[i] == "\\" and i + 1 < len(text): + out.append(text[i + 1]) + i += 2 + else: + out.append(text[i]) + i += 1 + return "".join(out) + + +def code_mask(joined: str) -> bytearray: + """单趟预扫:1=码位(非字符串内部、非注释)。字符串与行尾注释位置置 0。""" + mask = bytearray(len(joined)) + in_str = False + i = 0 + n = len(joined) + while i < n: + ch = joined[i] + if in_str: + mask[i] = 0 + if ch == "\\" and i + 1 < n: + mask[i + 1] = 0 + i += 2 + continue + if ch == '"': + in_str = False + i += 1 + continue + if ch == '"': + in_str = True + mask[i] = 0 + i += 1 + continue + if ch == "#": + nl = joined.find("\n", i) + end = n if nl == -1 else nl + for j in range(i, end): + mask[j] = 0 + i = end + continue + mask[i] = 1 + i += 1 + return mask + + +def func_table(lines): + """顶层函数表:[(name, start_line_1based)]。lambda 归宿主函数。""" + funcs = [] + for i, line in enumerate(lines, 1): + m = FUNC_RE.match(line) + if m: + funcs.append((m.group(2), i)) + return funcs + + +def func_at(funcs, lineno): + name = "" + for fn, start in funcs: + if start <= lineno: + name = fn + else: + break + return name + + +def split_args(body): + """按括号配平拆实参(顶层逗号分隔)。body=check( 与最外层 ) 之间的文本(换行已并空格)。""" + args = [] + depth = {"(": 0, "[": 0, "{": 0} + cur = [] + in_str = False + i = 0 + while i < len(body): + ch = body[i] + if in_str: + if ch == "\\" and i + 1 < len(body): + cur.append(body[i]) + cur.append(body[i + 1]) # 转义序列原样入账 + i += 2 + continue + if ch == '"': + in_str = False + cur.append(ch) + elif ch == '"': + in_str = True + cur.append(ch) + elif ch in depth: + depth[ch] += 1 + cur.append(ch) + elif ch in CLOSERS: + depth[CLOSER_OF[ch]] -= 1 + cur.append(ch) + elif ch == "," and depth["("] == 0 and depth["["] == 0 and depth["{"] == 0: + args.append("".join(cur)) + cur = [] + else: + cur.append(ch) + i += 1 + args.append("".join(cur)) + return args + + +def collect_call(joined, start): + """从 check( 的 start 起括号配平归集调用。返回 (body, end_pos);不配平返回 None。""" + i = start + len("check(") + depth = 1 + in_str = False + parts = [] + n = len(joined) + while i < n: + ch = joined[i] + if in_str: + if ch == "\\": + parts.append(joined[i:i + 2]) + i += 2 + continue + if ch == '"': + in_str = False + parts.append(ch) + elif ch == '"': + in_str = True + parts.append(ch) + elif ch in OPENERS: + depth += 1 + parts.append(ch) + elif ch in CLOSERS: + depth -= 1 + if depth == 0: + return "".join(parts), i + parts.append(ch) + elif ch == "#": + nl = joined.find("\n", i) + i = n if nl == -1 else nl # 行尾注释不进 body(并一空格) + parts.append(" ") + continue + else: + parts.append(ch if ch != "\n" else " ") + i += 1 + return None, i + + +def extract(src_path: Path): + """提取单文件全部静态 check( 调用点。返回(条目列表, 函数表),条目保持源码顺序。""" + src = src_path.read_text(encoding="utf-8") + lines = src.split("\n") + funcs = func_table(lines) + offsets = [] + total = 0 + for line in lines: + offsets.append(total) + total += len(line) + 1 + joined = "\n".join(lines) + mask = code_mask(joined) + + def line_of(pos): + i = bisect.bisect_right(offsets, pos) - 1 + return i + 1 + + entries = [] + for m in CALL_RE.finditer(joined): + if not mask[m.start()]: + continue # 字符串字面量内 / 注释里的 check( 字样 + ln = line_of(m.start()) + if DEF_RE.match(lines[ln - 1]): + continue # func check 定义行 + prev = joined[m.start() - 1] if m.start() > 0 else "" + if prev and (prev.isalnum() or prev == "_"): + continue # `_check(` 等同名前缀——非标准通道 + label_first = prev == "." # Kit.check("label", cond)——点前缀=标签在前 + body, end_pos = collect_call(joined, m.start()) + if body is None: + raise SystemExit("[extract_checks] FAIL:%s 行 %d 起的 check( 括号不配平到文件尾——先查解析缺口" + % (src_path.name, ln)) + args = split_args(body) + if len(args) not in (2, 3): + raise SystemExit( + "[extract_checks] FAIL:%s 行 %d 的 check( 拆出 %d 个实参(预期 2/3)——解析缺口,先查再跑" + % (src_path.name, ln, len(args))) + if label_first: + label_expr, cond = args[0], args[1] + else: + cond, label_expr = args[0], args[1] + quiet = args[2] if len(args) == 3 else "" + lm = LITERAL_RE.search(label_expr) + if lm is None: + continue # 标签实参无字面量(wrapper 直传变量——既有套件先例 reward_overlay_test _req) + # 不取:无可指纹化面;冻结面对账不受影响(字面量损坏会以「消失」呈现) + entries.append({ + "line": ln, + "end_line": line_of(min(end_pos, len(joined) - 1)), + "func": func_at(funcs, ln), + "label_literal": lm.group(0), # 含引号,原文逐字 + "label_suffix": label_expr[lm.end():], # 格式化/拼接后缀(通常为空或 % ...) + "cond": cond, + "quiet": quiet, + }) + return entries, funcs + + +def extract_all(src_paths): + """多文件提取合并。返回(条目列表, {相对路径: 函数表});条目附加 src=相对路径。""" + entries = [] + funcs_by_src = {} + for p in src_paths: + es, funcs = extract(p) + rel = p.relative_to(ROOT).as_posix() if p.is_relative_to(ROOT) else p.as_posix() + for e in es: + e["src"] = rel + entries.extend(es) + funcs_by_src[rel] = funcs + return entries, funcs_by_src + + +def label_cell(e): + """标签列:字面量 ↩ 后缀(后缀空则只有字面量)。""" + sfx = fold(e["label_suffix"]) + return e["label_literal"] + (" ↩ " + sfx if sfx else "") + + +def entry_key(e): + """对账指纹=合同 §五三要素:cond 折叠 + 标签字面量(逐字)+ 后缀折叠。 + quiet 不入指纹——它是 smoke 通道的打印档位(只影响 PASS 行打印,计数/红绿同口径), + Kit 通道无此参,迁移时按接线注记落通道转换;quiet 列保留展示,不参与对账。""" + return (fold(e["cond"]), e["label_literal"], fold(e["label_suffix"])) + + +def pad3(n): + return "S%02d" % n if n < 100 else "S%03d" % n + + +def base_commit(): + try: + r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(ROOT), + capture_output=True, text=True, encoding="utf-8") + if r.returncode == 0: + return r.stdout.strip() + except OSError: + pass + return "" + + +def render_list(entries, funcs_by_src, src_names) -> str: + """生成验证集清单骨架。白话释义/处置两列留待回填(回填不动冻结列)。 + 多源按(源文件, 函数)分章——跨文件同名函数不并章;章头行号=该源文件内行号。""" + out = [] + out.append("# 验证集清单(迁移参照物·冻结)") + out.append("") + out.append("> 生成日期:2026-10-22 · 基点 commit:`%s` · 源:%s" % (base_commit(), "、".join(src_names))) + out.append("> 提取口径:静态 `check(` 调用点,括号配平归集(可跨行)。批 B 基点实测 smoke_all.gd 调用点 1065 处;") + out.append("> 行级 `grep -c \"check(\"` = 1067 是另一口径——多出的 2 行为 `func check(` 定义行与") + out.append("> 一处注释行(「禁恒真 check(true)」),非调用点。运行时计数大于静态计数=循环展开条目,") + out.append("> 两口径各自浮动,禁互相换算(tests/README「断言计数双口径」)。") + out.append("> 生成命令:`python tools/extract_checks.py --extract`(可复现;重新生成须 --force)。") + out.append("> 冻结纪律:每条的「标签原文」「cond 摘要」「quiet」三列为机器提取逐字保真,禁任何改写;") + out.append("> cond 摘要=原文跨行折叠单行(连续空白并一空格),单元格内 `|`→`\\|`、`\\`→`\\\\` 转义,读回还原。") + out.append("> 「白话释义」「处置」两列由维护回填,不属冻结面。") + out.append("> 出口对账:`python tools/extract_checks.py --diff game/tests/验证集清单.md --src <宿主文件>...`") + out.append("> ——标签/cond/计数三对齐才算迁移完(合同 §五出口机检)。") + out.append("> 维护义务:此后断言新增/删除/改判必须同步本清单(test-conventions §9.3 锚)。") + out.append("") + seq = 0 + by_gf = [] + for e in entries: + key = (e["src"], e["func"]) + if by_gf and by_gf[-1][0] == key: + by_gf[-1][1].append(e) + else: + by_gf.append((key, [e])) + for idx, ((src, fn), es) in enumerate(by_gf, 1): + start, end = es[0]["line"], es[-1]["end_line"] + out.append("## %s %s(行 %d–%d · %d 条 · 源 %s · 去向:待分类回填)" % (pad3(idx), fn, start, end, len(es), src)) + out.append("") + out.append("| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet |") + out.append("|---|---|---|---|---|---|---|") + for e in es: + seq += 1 + out.append("| %d | %d | %s | %s | (待填) | | %s |" % ( + seq, e["line"], esc_cell(label_cell(e)), esc_cell(fold(e["cond"])), esc_cell(fold(e["quiet"])))) + out.append("") + out.append("---") + out.append("全源共 %d 章、%d 条(静态调用点口径)。" % (len(by_gf), seq)) + return "\n".join(out) + "\n" + + +CELL_SPLIT_RE = re.compile(r"(? int: + cur, _funcs = extract_all(src_paths) + frozen = parse_list(list_path) + cur_keys = {} + for e in cur: + cur_keys.setdefault(entry_key(e), []).append(e) + frozen_keys = {} + for e in frozen: + frozen_keys.setdefault(entry_key(e), []).append(e) + missing = [k for k in frozen_keys if k not in cur_keys] + added = [k for k in cur_keys if k not in frozen_keys] + # 成对归类:cond 同→标签变更;标签同→cond 变更 + label_changes, cond_changes = [], [] + used_added = set() + rest_missing = [] + for k in missing: + cond, lit, sfx = k + pair = next((ka for ka in added if ka not in used_added and ka[0] == cond), None) + if pair is not None: + used_added.add(pair) + label_changes.append((k, pair)) + continue + pair = next((ka for ka in added if ka not in used_added and ka[1] == lit and ka[2] == sfx), None) + if pair is not None: + used_added.add(pair) + cond_changes.append((k, pair)) + continue + rest_missing.append(k) + rest_added = [k for k in added if k not in used_added] + ok = True + print("[extract_checks] 对账:%s vs 现提取 %d 个源文件" % (list_path.name, len(src_paths))) + print(" 清单条数=%d 现提取条数=%d %s" % ( + len(frozen), len(cur), "(计数对齐)" if len(frozen) == len(cur) else "(计数不齐)")) + + def show(k, tag, q=""): + cond, lit, sfx = k + s = " %s cond=%s label=%s" % (tag, _short(cond), _short(lit)) + if sfx: + s += " suffix=%s" % _short(fold(sfx)) + if q: + s += " quiet=%s" % fold(q) + print(s) + + if label_changes: + ok = False + print(" 标签字面变更 %d 对(cond 同、标签异):" % len(label_changes)) + for old, new in label_changes: + show(old, "-", frozen_keys[old][0].get("quiet", "")) + show(new, "+", cur_keys[new][0].get("quiet", "")) + if cond_changes: + ok = False + print(" cond 字面变更 %d 对(标签同、cond 异):" % len(cond_changes)) + for old, new in cond_changes: + show(old, "-", frozen_keys[old][0].get("quiet", "")) + show(new, "+", cur_keys[new][0].get("quiet", "")) + if rest_missing: + ok = False + print(" 消失 %d 条(清单有、现码无):" % len(rest_missing)) + for k in rest_missing: + print(" - [%s]" % frozen_keys[k][0].get("section", "?"), end="") + show(k, "", frozen_keys[k][0].get("quiet", "")) + if rest_added: + ok = False + print(" 新增 %d 条(现码有、清单无):" % len(rest_added)) + for k in rest_added: + es = cur_keys[k][0] + print(" + [src %s func %s @%d]" % (es["src"], es["func"], es["line"]), end="") + show(k, "", es.get("quiet", "")) + if ok: + print("[extract_checks] PASS——标签/cond/计数三对齐,零差异") + return 0 + print("[extract_checks] FAIL——见上差异清单") + return 1 + + +def list_funcs(entries, funcs_by_src): + for src, funcs in funcs_by_src.items(): + by_func = [] + for e in entries: + if e["src"] != src: + continue + if by_func and by_func[-1][0] == e["func"]: + by_func[-1][1] = by_func[-1][1] + 1 + else: + by_func.append([e["func"], 1]) + print("=== %s ===" % src) + checked = {f for f, _ in by_func} + for fn, n in by_func: + print("%s\t%d" % (fn, n)) + print("--- 无断言函数(helper,随套迁移)---") + for fn, ln in funcs: + if fn not in checked: + print("%s\t@%d" % (fn, ln)) + + +def resolve_sources(src_args): + """--src 列表 → Path 列表(相对路径按仓库根解析);缺省=三宿主遍历。任一文件不可读即 None。""" + if src_args: + paths = [Path(s) if Path(s).is_absolute() else ROOT / s for s in src_args] + else: + paths = default_sources() + if not paths: + print("[extract_checks] FAIL:断言源为空(三宿主遍历无命中?)") + return None + for p in paths: + if not p.is_file(): + print("[extract_checks] FAIL:源文件不可读:%s" % p) + return None + return paths + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + ap = argparse.ArgumentParser(description="断言静态提取器(清单生成/对账)") + ap.add_argument("--extract", action="store_true", help="生成验证集清单") + ap.add_argument("--diff", metavar="清单文件", help="与清单逐条对账(批 B 出口机检)") + ap.add_argument("--list-funcs", action="store_true", help="打印函数清单与条数(编账本辅助)") + ap.add_argument("--src", nargs="+", default=None, + help="断言源文件(可多个;缺省=三宿主遍历 unit/**+integration/**+system_test.gd)") + ap.add_argument("--out", default=str(DEFAULT_OUT), help="--extract 输出路径") + ap.add_argument("--force", action="store_true", help="输出文件已存在时允许覆盖") + args = ap.parse_args() + if sum(bool(x) for x in (args.extract, args.diff, args.list_funcs)) != 1: + print("[extract_checks] 用法:--extract / --diff <清单文件> / --list-funcs 三选一") + return 2 + src_paths = resolve_sources(args.src) + if src_paths is None: + return 2 + if args.extract: + out = Path(args.out) + if out.exists() and not args.force: + print("[extract_checks] FAIL:清单已存在(%s)——冻结参照物防误覆,重生成须加 --force" % out) + return 2 + entries, funcs_by_src = extract_all(src_paths) + out.write_text(render_list(entries, funcs_by_src, [p.name for p in src_paths]), encoding="utf-8", newline="\n") + print("[extract_checks] 已生成 %s(%d 个源文件、%d 个调用点)" % (out, len(src_paths), len(entries))) + return 0 + if args.diff: + lp = Path(args.diff) + if not lp.is_file(): + print("[extract_checks] FAIL:清单不可读:%s" % lp) + return 2 + return diff_report(lp, src_paths) + entries, funcs_by_src = extract_all(src_paths) + list_funcs(entries, funcs_by_src) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/golden_compare.py b/tools/golden_compare.py index af8bbea6..c98701df 100644 --- a/tools/golden_compare.py +++ b/tools/golden_compare.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -r"""golden_compare.py — C 档黄金图对比(呈现层测试分级·周期重闸件)。 +r"""golden_compare.py — C 档黄金图对比(呈现层测试分级·渲染闸周期件)。 用法: python tools/golden_compare.py <黄金图目录> <新截图目录> [屏清单...] 默认屏清单 = shot_menu.png shot_camp.png shot_level.png shot_battle.png。 diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index d4f46866..fb8a0e2c 100644 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 轻闸探针正主——headless 安全段固化,禁再手拼同款 Godot 命令。 +# 探针闸正主——headless 安全段固化,禁再手拼同款 Godot 命令。 # 三闸:①每段 timeout 守卫(挂死=FAIL 不静默)②段前预清+段后清本 game 目录孤儿 Godot # (按命令行精确匹配本 game 路径,不误杀其他 worktree 会话的合法进程)③末尾残留清点=批末闸(必须 0)。 # 段:ui_input_probe=默认必跑;ui_struct_probe=默认必跑( @@ -12,8 +12,8 @@ # 永远为真的假断言,禁用)。 # 判定口径(三硬闸):每段走 verdict 函数——①退出码为唯一权威判据(PASS 串降级为日志) # ②脚本错误扫描命中即红(tools/error_scan.py,标记与 run_smoke.py 同源)③PASS 串保留为附加判据。 -# --full 段(重闸件):ui_probe/ui_tune_probe/t17_defer_probe/fx_probe -# 四件带断言探针;轻闸默认段断言面零删减零改动(--full 只增不改)。 +# --full 段(扩展段):ui_probe/ui_tune_probe/t17_defer_probe/fx_probe +# 四件带断言探针;默认段断言面零删减零改动(--full 只增不改)。 # 渲染面归 render_gate.sh 正主(窗口化:shot_probe 七屏+fx_shot/board_shot 取证+棋盘 golden 逐像素)。 # 用法: tools/probe_gate.sh [game目录] [--click] [--full] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree) # 依赖: Git Bash(coreutils timeout/taskkill) + PowerShell;Godot 路径可用环境变量 GODOT_BIN 覆盖。 @@ -34,7 +34,7 @@ TMP="$(mktemp -d /tmp/probegate_XXXXXX)" trap 'rm -rf "$TMP"' EXIT _rc=0 -# 错误扫描闸件用纯标准库 python(不依赖 PIL),优先带 PIL 的 gamedev venv 以保持与重闸同解释器。 +# 错误扫描闸件用纯标准库 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 diff --git a/tools/run_all_gates.sh b/tools/run_all_gates.sh index b6b60164..5dbae261 100644 --- a/tools/run_all_gates.sh +++ b/tools/run_all_gates.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 重闸唯一入口(迭代完成前必须全量跑一遍所有测试)—— +# 验证闸唯一入口(全部验证闸的清单与域划分,批末与合回按改动域跑)—— # 闸清单的唯一定义处。文档只写「收口跑本脚本」,禁在文档里另列闸件清单(那正是 iter27 # 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。 # @@ -7,8 +7,9 @@ # .zcode/hooks/worktree-guard.sh 在主工作区 git merge 时校验本回执,缺或红即拦截。 # # 用法:tools/run_all_gates.sh [--code|--docs|--quick] -# --code 只跑代码域闸(01 冒烟/02 单元/03 探针 --click/04 探针 --full/05 渲染闸/09 静态扫描 -# /11 回放确定性对拍+10 熵增闸)——本批改动触及 game/ 时用(游戏工程的唯一代码验收域)。 +# --code 只跑代码域闸(00 冒烟测试/01 系统测试/02 单元与集成测试/03 探针 --click/ +# 04 探针 --full/05 渲染闸/09 静态扫描/11 回放确定性对拍+10 熵增闸) +# ——本批改动触及 game/ 时用(游戏工程的唯一代码验收域)。 # --docs 只跑文档域闸(06 gdd_lint/07 doc_link/08 doc_term/08c 流水线闸/08d 引用可核 # +10 熵增闸) # ——本批改动不触及 game/ 时用:design/、根规则文件、tools/ 工具脚本、.zcode/ 配置等 @@ -116,7 +117,7 @@ fi declare -a NAMES=() EXITS=() LOGS=() FAILED=0 -echo "重闸跑批 · 域=$DOMAIN · 仓库 $REPO" +echo "闸机跑批 · 域=$DOMAIN · 仓库 $REPO" echo "起跑 commit: $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '非git目录')" echo "python: $PY" echo "godot: $GODOT" @@ -139,7 +140,7 @@ gate() { # gate <名称> <日志名> <命令...> } gate_unit() { # run_all 是 --script 模式,退出码为唯一判据 - local name="02 单元套件" + local name="02 单元与集成测试" echo "" echo "════════════════════════════════════════════" echo " $name(--script 模式,退出码为唯一判据)" @@ -158,11 +159,13 @@ gate_unit() { # run_all 是 --script 模式,退出码为唯一判据 return 0 } -# ══ 代码域(1–5)══ +# ══ 代码域(0–5)══ if in_domain code; then -# ── 1. 系统级断言面(冒烟三闸:退出码+摘要失败数+SCRIPT ERROR 扫描,判定无洞)── -gate "01 冒烟全量" "smoke.log" "$PY" "$REPO/tools/run_smoke.py" -# ── 2. 单元套件 ── +# ── 0. 冒烟测试(极小启动场景:真实开机路径存活检查,零逻辑判定,秒级)── +gate "00 冒烟测试" "smoke_boot.log" "$PY" "$REPO/tools/run_smoke.py" +# ── 1. 系统测试(真实玩法路径断言面;宿主=game/tests/system_test.gd,缺省 --script 由 run_system_test.py 持有)── +gate "01 系统测试" "system_test.log" "$PY" "$REPO/tools/run_system_test.py" +# ── 2. 单元与集成测试 ── gate_unit # ── 3. 场景探针 · 行为走查段 ── gate "03 探针 --click" "probe_click.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --click diff --git a/tools/run_smoke.py b/tools/run_smoke.py index 15a1e8e1..f6b756cd 100644 --- a/tools/run_smoke.py +++ b/tools/run_smoke.py @@ -1,18 +1,21 @@ #!/usr/bin/env python3 -r"""run_smoke.py — 冒烟运行器(脚本错误外部门禁) +r"""run_smoke.py — 冒烟测试运行器(脚本错误外部门禁) 用法: - python tools/run_smoke.py # 默认跑切片全量冒烟(本机 Godot 固定路径) - python tools/run_smoke.py --script res://smoke/smoke_all.gd - python tools/run_smoke.py --godot <引擎路径> --path <切片目录> --timeout 180 + python tools/run_smoke.py # 跑冒烟测试(game/tests/smoke_boot.tscn,本机 Godot 固定路径) + python tools/run_smoke.py --scene res://tests/smoke_boot.tscn + python tools/run_smoke.py --godot <引擎路径> --path <切片目录> --timeout 120 -背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、 -不计入冒烟失败数,且会静默中断所在冒烟函数的剩余断言——摘要零失败也可能断言未跑全。 +冒烟测试 = 极小启动场景(game/tests/smoke_boot.tscn/.gd):以项目运行目标拉起=真实开机路径 +(autoload 注册在位、配置表加载、主屏场景实例化、启动态菜单可见),断言只查存活,零逻辑判定。 + +背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、不计入失败数, +且会静默中断所在断言函数的剩余断言——摘要零失败也可能断言未跑全。 进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, 另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 -三闸: ① 进程退出码 = 0;② 摘要行「冒烟完成:N 项检查,0 项失败」在案且失败数为 0; - ③ 输出无「SCRIPT ERROR」「Parse Error」标记行。 +三闸: ① 进程退出码 = 0;② 摘要行「[smoke] 摘要:N 项检查,0 项失败」在案且失败数为 0; + ③ 输出无「SCRIPT ERROR」「Parse Error」标记行(标记清单供 tools/error_scan.py 复用,禁另写一份)。 """ import argparse import re @@ -23,22 +26,22 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DEFAULT_GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" DEFAULT_SLICE = ROOT / "game" -DEFAULT_SCRIPT = "res://smoke/smoke_all.gd" -SUMMARY_RE = re.compile(r"冒烟完成:(\d+) 项检查,(\d+) 项失败") +DEFAULT_SCENE = "res://tests/smoke_boot.tscn" +SUMMARY_RE = re.compile(r"\[smoke\] 摘要:(\d+) 项检查,(\d+) 项失败") ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") - ap = argparse.ArgumentParser(description="冒烟运行器(脚本错误外部门禁)") + ap = argparse.ArgumentParser(description="冒烟测试运行器(脚本错误外部门禁)") ap.add_argument("--godot", default=DEFAULT_GODOT) ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)") - ap.add_argument("--script", default=DEFAULT_SCRIPT) - ap.add_argument("--timeout", type=int, default=180, help="外部超时秒数(硬性要求)") + ap.add_argument("--scene", default=DEFAULT_SCENE, help="冒烟场景(项目运行目标)") + ap.add_argument("--timeout", type=int, default=120, help="外部超时秒数(硬性要求)") args = ap.parse_args() - cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script] + cmd = [args.godot, "--headless", "--path", args.path, args.scene] try: proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=args.timeout) @@ -55,12 +58,12 @@ def main(): gates.append(f"退出码 = {proc.returncode}(≠0)") m = SUMMARY_RE.search(out) if m is None: - gates.append("摘要行缺失(未检出「冒烟完成:N 项检查,M 项失败」——入口跑完前中断?)") + gates.append("摘要行缺失(未检出「[smoke] 摘要:N 项检查,M 项失败」——场景跑完前中断?)") elif int(m.group(2)) != 0: gates.append(f"冒烟失败 {m.group(2)} 项 / 共 {m.group(1)} 项") hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)] if hits: - gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计冒烟失败数," + gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计失败数," "且中断所在函数的剩余断言,必须人工排查)") if m is not None: diff --git a/tools/run_system_test.py b/tools/run_system_test.py new file mode 100644 index 00000000..9d49bbe2 --- /dev/null +++ b/tools/run_system_test.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +r"""run_system_test.py — 系统测试运行器(脚本错误外部门禁) + +用法: + python tools/run_system_test.py # 跑系统测试(宿主 res://tests/system_test.gd) + python tools/run_system_test.py --script <宿主脚本> + python tools/run_system_test.py --godot <引擎路径> --path <切片目录> --timeout 300 + +系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一)。 +宿主:res://tests/system_test.gd(--script 薄壳自持 check 通道;断言面自 smoke_all.gd 迁入——沿革查 git log)。 + +背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、不计入失败数, +且会静默中断所在断言函数的剩余断言——摘要零失败也可能断言未跑全。 +进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, +另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 + +三闸: ① 进程退出码 = 0;② 摘要行(「冒烟完成:N 项检查,0 项失败」或 + 「[system_test] 摘要:N 项检查,0 项失败」)在案且失败数为 0; + ③ 输出无「SCRIPT ERROR」「Parse Error」标记行(标记清单经 tools/error_scan.py 复用,禁另写一份)。 +""" +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" +DEFAULT_SLICE = ROOT / "game" +DEFAULT_SCRIPT = "res://tests/system_test.gd" # 系统测试宿主 +SUMMARY_RE = re.compile(r"(?:冒烟完成|\[system_test\] 摘要):(\d+) 项检查,(\d+) 项失败") +ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") + + +def main(): + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + ap = argparse.ArgumentParser(description="系统测试运行器(脚本错误外部门禁)") + ap.add_argument("--godot", default=DEFAULT_GODOT) + ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)") + ap.add_argument("--script", default=DEFAULT_SCRIPT, help="系统测试宿主脚本") + ap.add_argument("--timeout", type=int, default=300, help="外部超时秒数(硬性要求)") + args = ap.parse_args() + + cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=args.timeout) + except subprocess.TimeoutExpired: + print(f"[run_system_test] FAIL:外部超时(>{args.timeout}s——硬性要求)") + return 1 + out = (proc.stdout or "") + (proc.stderr or "") + sys.stdout.write(out) + if out and not out.endswith("\n"): + print() + + gates = [] + if proc.returncode != 0: + gates.append(f"退出码 = {proc.returncode}(≠0)") + m = SUMMARY_RE.search(out) + if m is None: + gates.append("摘要行缺失(未检出「冒烟完成:N 项检查,M 项失败」——宿主跑完前中断?)") + elif int(m.group(2)) != 0: + gates.append(f"系统测试失败 {m.group(2)} 项 / 共 {m.group(1)} 项") + hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)] + if hits: + gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计失败数," + "且中断所在函数的剩余断言,必须人工排查)") + + if m is not None: + print(f"[run_system_test] 摘要: {m.group(0)}") + if gates: + print("[run_system_test] FAIL——") + for g in gates: + print(f" - {g}") + return 1 + print("[run_system_test] PASS(退出码 / 摘要失败数 / 脚本错误扫描 三闸全过)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/体验验证清单.md b/体验验证清单.md index e7b87e1f..7e0f3c30 100644 --- a/体验验证清单.md +++ b/体验验证清单.md @@ -118,7 +118,7 @@ > **✅ 转记(2026-10-16 用户令屏蔽——前提失效,无需验)**:贫矿脉行已从 `variation.tres` 删除(用户裁「地形变异特异关卡后续重新做设计,先屏蔽」;Boss/精英图全局池空→region 布置兜底,玩家面零横幅零角标零资源偏斜)。下列六项判据全部失效,不验;重建设计立项时随新需求卡重立目检项。改动面=shielddebug-20261016 批(variation.tres 删行+冒烟断言换代)。 -> **改排状态注记(2026-10-05 ui-struct-probe 批)**:UI 优化批(UI 落码 11 卡+SR-1)已全入库;期间实机目检抓到标题屏双列叠放缺陷,已随 ui-struct-probe 批修复,并新增全 UI 屏结构探针入轻闸(按钮互压/等宽/出屏/零尺寸——机械可判项自此探针代管,见 §⑯)。本条统一验证窗口仍开,等用户实机目检时点;下列六项均为体感/呈现面,保留人闸。 +> **改排状态注记(2026-10-05 ui-struct-probe 批)**:UI 优化批(UI 落码 11 卡+SR-1)已全入库;期间实机目检抓到标题屏双列叠放缺陷,已随 ui-struct-probe 批修复,并新增全 UI 屏结构探针入探针闸默认段(tools/probe_gate.sh,按钮互压/等宽/出屏/零尺寸——机械可判项自此探针代管,见 §⑯)。本条统一验证窗口仍开,等用户实机目检时点;下列六项均为体感/呈现面,保留人闸。 **怎么玩**:打一局正常打到第 2 关的 Boss 图(想快可以在开始界面开调试入口直接进第 2 关);顺手的话再看一眼第 1 关 Boss 和任意普通战斗图,前后共约十分钟。本条目在 UI 优化批落地后执行——届时以改完后的最终界面为准,下述文案若随 UI 批调整以当批登记为准。 @@ -162,12 +162,17 @@ ### ⑮ 秘宝词族换词全 UI 面目检 + 渲染像素闸补跑(2026-10-05 秘宝代码词族对齐批顺带登记——迭代15③号卡) - **实机目检(「秘宝」字样全 UI 面,约 3 分钟)**:①C 区槽位标题应显示「秘宝」(原「魔法道具」,hero_info_panel_view 槽位组);②空槽悬停提示应显示「秘宝槽(空)」;③战斗总结三选一叙述行应显示「秘宝 / …」或「秘宝 / 随机获得一件」(原「魔法道具」开头);④换关 toast 应显示「上关剩余 N 件秘宝已随关卡结束清空」。**红灯信号**:任何界面残留「魔法道具」字样。〔**✅ 真人目检半已核销(2026-10-14)**:用户实机目检回报完成,未呈报残留(qa 收尾批)〕 -- **渲染像素类闸补跑挂账**:本批屏蔽五件(golden_compare / shot_probe / fx_probe / ui_layout_probe / board_shot——组 A 文案变更使像素断言合法失效,按 goal 令弃断);**补跑时点=迭代 14 全入库后的表现批周期闸一次性补跑**,与本批无独立补跑义务;届时若 golden 基线因文案换代失效,按换代流程重锚而非当缺陷修。**分工注记(2026-10-05 ui-struct-probe 批)**:结构类断言(互压/等宽/出屏)已由 `ui_struct_probe` 入轻闸默认必跑段(tools/probe_gate.sh),与像素五闸互补不替代——五闸补跑义务不变。**核销注记(2026-10-08 迭代 19 卡①自动化验收补齐批——机器补跑半顺带核销)**:五件机器半全部归宿已入库并实测绿——shot_probe(扩屏七屏+shot_checker A-D 断言)/board_shot(L1-L3 取证+golden 逐像素 0.00%×3)/golden_compare 三件入 render_gate(窗口化自动闸),fx_probe 入 probe_gate --full(headless 逻辑层断言),ui_layout_probe 补 1280/1920 两档最小断言(首跑 PASS);五闸归宿对账见需求卡片-自动化验收补齐批 §一。**真人目检半(上方「秘宝」字样全 UI 面 3 分钟)仍归用户,不注销**。 +- **渲染像素类闸补跑挂账**:本批屏蔽五件(golden_compare / shot_probe / fx_probe / ui_layout_probe / board_shot——组 A 文案变更使像素断言合法失效,按 goal 令弃断);**补跑时点=迭代 14 全入库后的表现批周期闸一次性补跑**,与本批无独立补跑义务;届时若 golden 基线因文案换代失效,按换代流程重锚而非当缺陷修。**分工注记(2026-10-05 ui-struct-probe 批)**:结构类断言(互压/等宽/出屏)已由 `ui_struct_probe` 入探针闸默认必跑段(tools/probe_gate.sh),与像素五闸互补不替代——五闸补跑义务不变。**核销注记(2026-10-08 迭代 19 卡①自动化验收补齐批——机器补跑半顺带核销)**:五件机器半全部归宿已入库并实测绿——shot_probe(扩屏七屏+shot_checker A-D 断言)/board_shot(L1-L3 取证+golden 逐像素 0.00%×3)/golden_compare 三件入 render_gate(窗口化自动闸),fx_probe 入 probe_gate --full(headless 逻辑层断言),ui_layout_probe 补 1280/1920 两档最小断言(首跑 PASS);五闸归宿对账见需求卡片-自动化验收补齐批 §一。**真人目检半(上方「秘宝」字样全 UI 面 3 分钟)仍归用户,不注销**。 ### ⑯ UI 结构批复验(2026-10-05 ui-struct-probe mini-loop 顺带登记——标题屏双列修复+按钮宽度纪律,约 2 分钟) +<<<<<<< HEAD +- **背景**:实机目检抓到标题屏立绘/帮助牌叠压操作行牌(M2 双列接线缺陷,ui_assembler.gd 右列误挂 MarginContainer)+全站按钮宽度无纪律(UiKit 工厂无宽度约束)。已修复:右列挂回 HBox 第二列;UiKit.button 全站最小宽度基准 216+SIZE_FILL(行板/角标/图标钮 9 处豁免);全 UI 屏结构探针入探针闸默认段。 +- **看什么(实机目检约 2 分钟)**:①标题屏应为左右双列——左操作列(冒险/营地与系统两组)与右立绘看台+玩法速览牌并排,互不遮挡,「退出游戏」按钮完整可见;②同组按钮等宽(菜单列/确认弹窗),按钮不再宽窄参差;③营地屏三柜台+底栏不出屏、不重叠。 +======= - **背景**:实机目检抓到标题屏立绘/帮助牌叠压操作行牌(M2 双列接线缺陷,ui_assembler.gd 右列误挂 MarginContainer)+全站按钮宽度无纪律(UiKit 工厂无宽度约束)。已修复:右列挂回 HBox 第二列;UiKit.button 全站最小宽度基准 216+SIZE_FILL(行板/角标/图标钮 9 处豁免);全 UI 屏结构探针入轻闸。 - **看什么(实机目检约 2 分钟)**:①标题屏应为左右双列——左操作列(进入存档/设置/退出游戏三钮——迭代 33 卡② 主菜单三入口化后形态,目检按新钮组验)与右立绘看台+玩法速览牌并排,互不遮挡,「退出游戏」按钮完整可见;②同组按钮等宽(菜单列/确认弹窗),按钮不再宽窄参差;③营地屏三柜台+底栏不出屏、不重叠。 +>>>>>>> master - **红灯信号**:任何屏出现控件叠压/按钮宽窄不一回潮/内容超出屏幕——结构探针已拦机检面,红灯=探针盲区,报批补断言。 ### ⑰ 奇物渠道分离与凭证目检(2026-10-05 奇物词族对齐与渠道分离批顺带登记——迭代15②号卡) @@ -265,7 +270,7 @@ > 场景拆分+会话物化+战斗域重构+账本完成+模态协议内聚后,玩家可见面唯一验收=观感零变化。登记不阻塞(2026-10-12 真人检测立规)。 > -> **机检承接注记(2026-10-17 用户裁,迭代 29 反哺点③)**:屏壳切换正确性半(六步主流程+两条回退+屏级互斥)已转机检 `screen_flow_probe` 入轻闸必跑段;第 1 项保留——其承载的是机检抓不到的观感半(逐屏演出时序/文案/布局零漂移)。 +> **机检承接注记(2026-10-17 用户裁,迭代 29 反哺点③)**:屏壳切换正确性半(六步主流程+两条回退+屏级互斥)已转机检 `screen_flow_probe` 入探针闸必跑段;第 1 项保留——其承载的是机检抓不到的观感半(逐屏演出时序/文案/布局零漂移)。 | # | 目检项 | 判据 | |---|---|---| diff --git a/待办清单.md b/待办清单.md index 333ce968..d720f1f8 100644 --- a/待办清单.md +++ b/待办清单.md @@ -2,6 +2,15 @@ 本清单是流水线的唯一登记处:要做的事先在此登记一条,随流水线段推进改状态,做完整条删除;各系统文档的「待办」节只留一行指路存根,不再自建条目。条目格式(标题=登记元数据,正文=现状/缺口/归属/状态四属性)与状态机以 [doc-conventions §12.1](design/engineering/doc-conventions.md) 为权威。 +<<<<<<< HEAD +#### 测试术语与层级重构批 A——测试命名+频率规则+新冒烟测试(2026-10-22 登记,来源=会话提及,设计面=工程直入) + +**现状**:测试域名实分离(名叫冒烟的载体实为千项断言面)、每轮跑闸频率过高、测试金字塔倒挂的规则重构,合同=reports/提案-测试术语与层级重构方案-2026-10-22.md(v2,2026-10-22 用户裁决全部闭合、待裁点清零)。批 A=非迭代批(规则与闸件批),用户 2026-10-22 直接下开工令执行:待办清单登记 → 改规则(process-conventions/roadmap loop 规范/术语表退役词对照/AGENTS.md 规则行)→ 写新冒烟测试(game/tests/smoke_boot.tscn/.gd+tools/run_smoke.py 重写)与系统测试宿主(tools/run_system_test.py+game/tests/system_test.gd 薄壳)→ 改闸机键映射(新增 00 冒烟测试、01 改系统测试、02 改单元与集成测试)→ 改描述性文档与 iter 技能三副本 → 全量域闸验证。批 B(断言迁移,用户特批免卡直改 game/)停机等用户发令,不在本条执行面。 + +**归属**:tools/ 闸机与测试基建+规则文档面(工程直入证据=提案 §一 四类问题证据附行号,用户裁决录在提案 §二) + +**状态**:待写卡 +======= #### 营地五界面窗口化批——营地功能页点开后的呈现形态(2026-10-22 登记,来源=会话提及,设计面=文档轨,优先级=**绝对**) **现状**:营地场景图(卡④)把旧四柜台换成「一张可点击的场景图」,从场景图点进去的**五个功能界面**——锻造工坊/秘宝匣/天赋树(树淬炼)/奖励格图鉴/生涯记录——其**呈现形态尚未定稿**:是各开独立全屏页(生涯先例),还是作为**窗口**打开(场景根留底)。**作者 2026-10-22 明示意图=窗口打开,不是全屏页**。该形态原属卡④ §9 代拍板白名单第 2 条(非用户裁、且「缩放虚化」第三形态禁批内自选),故单独立批,不在美术设计段定值。 @@ -13,6 +22,7 @@ **归属**:卡④ §9 代拍板域 → 独立成批(呈现层 + 文档轨);窗口形态与叠层规则写入 `LAYER_CONTRACT` 与 [camp](design/gdd/ux/camp.md)「叠层与开合」节 **状态**:待文档设计 +>>>>>>> master #### 验收闭合规则——下一次迭代实测评估(2026-10-21 登记,来源=审计发现,设计面=工程直入) @@ -499,7 +509,7 @@ #### 「生涯」界面——冒险中数据回看的唯一承接处(2026-10-04 登记,来源=会话提及,设计面=文档轨) -**现状**:2026-10-04 用户裁决(UI 落码计划 P-C7)——冒险中不给数据回看:settlement_review_overlay(结算明细回看)与结算框「明细回看」钮在冒险中退役,战报带只留单行「上轮」;历史战报/结算明细的查看去向=新「生涯」界面(跨局汇总/历史冒险回看/图鉴承接)。来源=UI 重设计原型 v2 落码计划裁决记录(已退役,git 历史可查 §七)。**设计提纲已成文**:[systems/跨局循环/生涯.md](design/gdd/systems/跨局循环/生涯.md) v0.1 范围草案五节成文〔信息架构/承载内容清单/与既有界面关系/分期实装建议/待裁点〕,P0-P5 六裁点已全裁并写入文档〔P0甲 提纲落现行事实源 / P1甲 主菜单一级入口 / P2甲 每局一行摘要+cap 扩容 N+补用时埋点 / P3甲 敌人图鉴优先 / P4甲 局内不放入口+生涯零跳转 / P5甲 settlement_review 复活承接〕。**期一已落地**(2026-10-07 迭代 18 卡④实装批:生涯页六类九项+主菜单入口+终身计数域存档 v6,批末重闸全绿;实机人闸=体验验证清单⑲ 生涯页目检节)。剩余面=期二(历史回看/图鉴)与期三实装+P6 营地最近冒险条带去留(新发现待裁点,随提纲评审一并裁)。**迭代 19 卡②遗留件(2026-10-08 登记)**:settlement_review_overlay.gd 现态考证=未接线遗留件(全仓零引用,M9 结算框改版「明细回看」钮退役后遗留)——处置两途:①随生涯实装批按 P5 甲复活承接(重接线入生涯);②确认不再需要则正式退役删除文件;二选一归生涯实装批/用户裁。Alpha 出口裁决:期二、期三全部排 Beta——Alpha 只保留期一,不在 Alpha 内做生涯新内容。 +**现状**:2026-10-04 用户裁决(UI 落码计划 P-C7)——冒险中不给数据回看:settlement_review_overlay(结算明细回看)与结算框「明细回看」钮在冒险中退役,战报带只留单行「上轮」;历史战报/结算明细的查看去向=新「生涯」界面(跨局汇总/历史冒险回看/图鉴承接)。来源=UI 重设计原型 v2 落码计划裁决记录(已退役,git 历史可查 §七)。**设计提纲已成文**:[systems/跨局循环/生涯.md](design/gdd/systems/跨局循环/生涯.md) v0.1 范围草案五节成文〔信息架构/承载内容清单/与既有界面关系/分期实装建议/待裁点〕,P0-P5 六裁点已全裁并写入文档〔P0甲 提纲落现行事实源 / P1甲 主菜单一级入口 / P2甲 每局一行摘要+cap 扩容 N+补用时埋点 / P3甲 敌人图鉴优先 / P4甲 局内不放入口+生涯零跳转 / P5甲 settlement_review 复活承接〕。**期一已落地**(2026-10-07 迭代 18 卡④实装批:生涯页六类九项+主菜单入口+终身计数域存档 v6,批末全量域闸全绿;实机人闸=体验验证清单⑲ 生涯页目检节)。剩余面=期二(历史回看/图鉴)与期三实装+P6 营地最近冒险条带去留(新发现待裁点,随提纲评审一并裁)。**迭代 19 卡②遗留件(2026-10-08 登记)**:settlement_review_overlay.gd 现态考证=未接线遗留件(全仓零引用,M9 结算框改版「明细回看」钮退役后遗留)——处置两途:①随生涯实装批按 P5 甲复活承接(重接线入生涯);②确认不再需要则正式退役删除文件;二选一归生涯实装批/用户裁。Alpha 出口裁决:期二、期三全部排 Beta——Alpha 只保留期一,不在 Alpha 内做生涯新内容。 **缺口**:还差提纲用户评审收尾与 P6 营地最近冒险条带去留裁决——裁完才能立期二/期三实装卡(期一已交付,期二/三未预占排期)。本条排 Beta,不阻塞 Alpha;重启时再补 P6 与 N 终值。