merge(批B 断言层级重构+iter33 卡② 移植): 1065 条断言自 smoke_all.gd 迁入 16 标准宿主(冒烟/单元/集成/系统+UI 探针四级);smoke_all.gd 退役;变异引擎按用户令删除、验证改核代码(三冷审 1065 全审+grilling 修订 10 条修复全部落地);并入 master 侧锻造工坊死链修复与迟到的 iter33 卡② 主菜单三入口化(mn1/mn8/mn2/mn9 断言换代移植进 system_test.gd);出口对账 1067/1067 三对齐零差异;全量域闸验收通过无新增红
This commit is contained in:
commit
faa0f98c4c
50 files changed
+11333
-170
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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」不作判据。另有两类静默失效必须机制性防范——规则扫描面为空
|
||||
|
||||
@@ -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<N>-实现报告-<日期>.md`(各卡状态+实机目检总清单+遗留读数义务)、代拍板追认单 `reports/iter<N>-代拍板-<日期>.md`、GDD 反哺点清单 `reports/iter<N>-反哺点-<日期>.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<N>-实现报告-<日期>.md`(各卡状态+实机目检总清单+遗留读数义务)、代拍板追认单 `reports/iter<N>-代拍板-<日期>.md`、GDD 反哺点清单 `reports/iter<N>-反哺点-<日期>.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. 测试执行两入口铁规(细则)
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
- **反向**:diff 里每个行为改动至少一行;没有对应行的改动,必须写明「非行为改动」或「由第 X 行覆盖」。
|
||||
- **测试侧**:新增或改写的每条断言至少挂一行;挂不上的孤儿断言删掉或补挂到需求点。
|
||||
|
||||
**验证集清单维护锚(2026-10-22 用户裁)**:`game/tests/验证集清单.md` 是迁移断言全体的冻结参照清单(机器提取生成、只剪切禁改写、出口逐条核对,机制见该文件头注;清单自断言迁移批第 0 步建立时起存在)——清单在册后,任何断言**新增、删除或改判**必须同步该清单,与改动—验收对照表同一条纪律:清单没跟上的断言改动视同未完成。
|
||||
|
||||
### 9.4 撤销证明
|
||||
|
||||
- **修改既有行为**:只撤销该改动(回退该 hunk 或该处接线)→ 跑能覆盖它的最窄测试 → 对应断言必须变红 → 还原 → 复绿。
|
||||
|
||||
@@ -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 用户令)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uid://bk7yf7sf5w3h3
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-35
@@ -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/<probe>.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/` |
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 迁入;试玩记录仪整套按裁决七转办集成宿主)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)")
|
||||
@@ -0,0 +1 @@
|
||||
uid://m7p3g1baece4
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 成员并采)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtoupwbm65jtw
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。
|
||||
@@ -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 取号断言——子类拦截计数,派生与取数语义零改动)
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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<hi,如 clear_blast)须实际流动。
|
||||
# 全部按 interval_rows 配置推导——新增/改区间行自动纳入,不逐行硬编码。
|
||||
var variance_ok := true
|
||||
var fixed_rows_ok := true
|
||||
for rid in interval_rows.keys():
|
||||
var lo: int = interval_rows[rid][0]
|
||||
var hi: int = interval_rows[rid][1]
|
||||
var samples: int = seen.get(rid, {}).size()
|
||||
if lo == hi:
|
||||
if samples > 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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# rebuild 树规则段对两键零合成行——此状态在此被显式断言(旧版对两键取空夹具静默跳过=假绿):
|
||||
# rebuild 实装面行数对照——四键逐键与夹具同行数(rebuild 分支被删/漂移即转红),
|
||||
# 两键断零行(将来被加合成行即转红,提示先复核效果闭集再同步夹具与实现)。
|
||||
# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。)
|
||||
# 载体:run_all 单元测试(显式实例化夹具——--script 模式无 autoload)。)
|
||||
extends SceneTree
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://decsopim6t4x5
|
||||
@@ -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
|
||||
+1469
File diff suppressed because it is too large.
Load diff
@@ -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 登记待办」作废——区间行死逻辑在测试源码自身,本批已修。
|
||||
@@ -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 现役断言(后续可选)。
|
||||
|
||||
**待裁点:无——全部闭合。**
|
||||
+5
-5
@@ -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<N>-反哺点-<日期>.md`,由文档批执行 ④阶段变更改 stage.txt ⑤批末闸序列**按本批改动域**全绿留痕(回执 commit 对齐分支尖)。
|
||||
|
||||
@@ -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"(?<!去)重闸|轻闸|三查", "2026-10-22", "(随频率规则消亡:检出工作区零测试;卡内只测自己改的东西;全量只有批末+合回两个时机)", "测试术语重构批——闸档用语退役;代码注释面由 S11 承接(S11 设「去重闸」同形异义豁免)"),
|
||||
]
|
||||
|
||||
DEFAULT_DIRS = ["design/gdd", "design/art"]
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""extract_checks.py — 断言静态提取器(验证集清单生成与出口对账)
|
||||
|
||||
机器提取宿主脚本的全部静态 check( 调用点(每个调用点记录:所在文件、所在函数、
|
||||
行号、标签字符串字面量原文、cond 表达式原文),提供两种模式:
|
||||
|
||||
用法:
|
||||
python tools/extract_checks.py --extract [--src <源文件>...] [--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 = "<module>"
|
||||
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 "<git 不可用,未取得基点 commit>"
|
||||
|
||||
|
||||
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"(?<!\\)\|")
|
||||
|
||||
|
||||
def parse_list(path: Path):
|
||||
"""读回清单:返回条目列表(键与 entry_key 同构;section 供报告归组)。"""
|
||||
entries = []
|
||||
cur_section = None
|
||||
for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1):
|
||||
if line.startswith("## "):
|
||||
m = re.match(r"## \S+ (\S+)(行", line)
|
||||
cur_section = m.group(1) if m else line[3:].strip()
|
||||
continue
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in CELL_SPLIT_RE.split(line)[1:-1]]
|
||||
if len(cells) != 7 or not cells[0].isdigit() or cells[0] == "序号":
|
||||
continue # 表头/分隔行
|
||||
label_raw = unesc_cell(cells[2])
|
||||
cond = unesc_cell(cells[3])
|
||||
quiet = unesc_cell(cells[6])
|
||||
lit_m = LITERAL_RE.match(label_raw)
|
||||
literal = lit_m.group(0) if lit_m else label_raw
|
||||
suffix = label_raw[lit_m.end():].strip() if lit_m else ""
|
||||
suffix = suffix[1:].strip() if suffix.startswith("↩") else suffix
|
||||
entries.append({
|
||||
"md_line": i,
|
||||
"section": cur_section,
|
||||
"label_literal": literal,
|
||||
"label_suffix": suffix,
|
||||
"cond": cond,
|
||||
"quiet": quiet,
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def _short(s, n=80):
|
||||
s = s.replace("\n", " ")
|
||||
return s if len(s) <= n else s[:n] + "…"
|
||||
|
||||
|
||||
def diff_report(list_path: Path, src_paths) -> 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())
|
||||
@@ -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。
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+12
-9
@@ -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
|
||||
|
||||
+19
-16
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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 项保留——其承载的是机检抓不到的观感半(逐屏演出时序/文案/布局零漂移)。
|
||||
|
||||
| # | 目检项 | 判据 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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 终值。
|
||||
|
||||
|
||||
Reference in new issue
Block a user