From 16c6f1362e58d7bf82eef96b8a3731f98290da53 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 02:09:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E6=B3=A22=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=94=B6=E5=B0=BE):=20game/smoke/=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=80=80=E5=BD=B9=E2=80=94=E2=80=9444=20=E4=BB=B6=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E4=B8=89=E4=BB=B6=E5=A5=97=E8=BF=81=20game/probe/?= =?UTF-8?q?=E3=80=8130=20=E4=BB=B6=E6=B5=8B=E9=87=8F/=E8=AF=8A=E6=96=AD/?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E4=BB=B6=E8=BF=81=20game/sim/=EF=BC=9B?= =?UTF-8?q?=E5=85=A8=E4=BB=93=20100+=20=E5=A4=84=20res://smoke=20=E4=B8=8E?= =?UTF-8?q?=20game/smoke=20=E5=BC=95=E7=94=A8=E6=94=B9=E6=8C=87=EF=BC=88?= =?UTF-8?q?=E9=97=B8=E4=BB=B6/=E4=BA=A7=E5=93=81=E7=A0=81=20tune=5Fsim=5Fc?= =?UTF-8?q?ontroller/=E5=AE=BF=E4=B8=BB=20preload/=E6=A0=B9=E8=A7=84?= =?UTF-8?q?=E5=88=99/README/=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E7=86=B5=E5=A2=9E=E9=97=B8=20RETIRED=20=E6=94=B6?= =?UTF-8?q?=E7=BC=96=20game/smoke=20=E9=98=B2=E5=9B=9E=E6=BD=AE=EF=BC=9BAG?= =?UTF-8?q?ENTS.md=20Project=20Layout=20=E6=8D=A2=E4=BB=A3=EF=BC=9Bstatic?= =?UTF-8?q?=5Fscan=20=E8=B1=81=E5=85=8D=E7=90=86=E7=94=B1=E6=8D=A2?= =?UTF-8?q?=E6=96=B0=E5=AE=BF=E4=B8=BB=EF=BC=9B=E6=B3=A2=202=20=E6=96=BD?= =?UTF-8?q?=E5=B7=A5=E4=BB=B6=E9=A2=84=E8=AE=A1=E7=AE=97=E8=A1=A8=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=91=BD=E4=B8=AD=EF=BC=888d=20=E9=A2=84=E6=89=AB?= =?UTF-8?q?=E9=9B=B6=E7=BA=A2=E7=81=AF=EF=BC=89=EF=BC=9B=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=9F=9F=E9=97=B8=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87=E9=9B=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 +- .../engineering/architecture/architecture.md | 8 +- design/engineering/code-conventions.md | 2 +- design/engineering/test-conventions.md | 6 +- design/gdd/systems/表现与支撑/数据配置.md | 2 +- design/gdd/术语表.md | 2 +- game/README.md | 14 +- game/presentation/tune_sim_controller.gd | 2 +- game/{smoke => probe}/board_shot_probe.gd | 4 +- game/{smoke => probe}/board_shot_probe.gd.uid | 0 game/{smoke => probe}/board_shot_probe.tscn | 2 +- game/{smoke => probe}/fx_probe.gd | 2 +- game/{smoke => probe}/fx_probe.gd.uid | 0 game/{smoke => probe}/fx_probe.tscn | 2 +- game/{smoke => probe}/fx_shot_probe.gd | 4 +- game/{smoke => probe}/fx_shot_probe.gd.uid | 0 game/{smoke => probe}/fx_shot_probe.tscn | 2 +- game/{smoke => probe}/layout_probe.gd | 2 +- game/{smoke => probe}/layout_probe.gd.uid | 0 game/{smoke => probe}/layout_probe.tscn | 2 +- game/{smoke => probe}/probe_ui_util.gd | 2 +- game/{smoke => probe}/probe_ui_util.gd.uid | 0 game/{smoke => probe}/screen_flow_probe.gd | 4 +- .../{smoke => probe}/screen_flow_probe.gd.uid | 0 game/{smoke => probe}/screen_flow_probe.tscn | 2 +- game/{smoke => probe}/shot_probe.gd | 4 +- game/{smoke => probe}/shot_probe.gd.uid | 0 game/{smoke => probe}/shot_probe.tscn | 2 +- game/{smoke => probe}/t17_defer_probe.gd | 4 +- game/{smoke => probe}/t17_defer_probe.gd.uid | 0 game/{smoke => probe}/t17_defer_probe.tscn | 2 +- game/{smoke => probe}/type_glow_shot_probe.gd | 4 +- .../type_glow_shot_probe.gd.uid | 0 .../type_glow_shot_probe.tscn | 2 +- game/{smoke => probe}/ui_click_probe.gd | 6 +- game/{smoke => probe}/ui_click_probe.gd.uid | 0 game/{smoke => probe}/ui_click_probe.tscn | 2 +- game/{smoke => probe}/ui_contrast_probe.gd | 2 +- .../{smoke => probe}/ui_contrast_probe.gd.uid | 0 game/{smoke => probe}/ui_contrast_probe.tscn | 2 +- game/{smoke => probe}/ui_input_probe.gd | 4 +- game/{smoke => probe}/ui_input_probe.gd.uid | 0 game/{smoke => probe}/ui_input_probe.tscn | 2 +- game/{smoke => probe}/ui_probe.gd | 4 +- game/{smoke => probe}/ui_probe.gd.uid | 0 game/{smoke => probe}/ui_probe.tscn | 2 +- game/{smoke => probe}/ui_struct_probe.gd | 4 +- game/{smoke => probe}/ui_struct_probe.gd.uid | 0 game/{smoke => probe}/ui_struct_probe.tscn | 2 +- game/{smoke => probe}/ui_tune_probe.gd | 4 +- game/{smoke => probe}/ui_tune_probe.gd.uid | 0 game/{smoke => probe}/ui_tune_probe.tscn | 2 +- game/{smoke => sim}/adventure_monte_carlo.gd | 4 +- .../adventure_monte_carlo.gd.uid | 0 game/{smoke => sim}/auto_player.gd | 0 game/{smoke => sim}/auto_player.gd.uid | 0 game/{smoke => sim}/battle_driver.gd | 2 +- game/{smoke => sim}/battle_driver.gd.uid | 0 game/{smoke => sim}/board_stat_probe.gd | 2 +- game/{smoke => sim}/board_stat_probe.gd.uid | 0 game/{smoke => sim}/calibrate_boards.gd | 2 +- game/{smoke => sim}/calibrate_boards.gd.uid | 0 game/{smoke => sim}/damage_probe.gd | 6 +- game/{smoke => sim}/damage_probe.gd.uid | 0 game/{smoke => sim}/defense_scan.gd | 4 +- game/{smoke => sim}/defense_scan.gd.uid | 0 game/{smoke => sim}/dump_battle.gd | 4 +- game/{smoke => sim}/dump_battle.gd.uid | 0 game/{smoke => sim}/experiment_density.gd | 0 game/{smoke => sim}/experiment_density.gd.uid | 0 game/{smoke => sim}/level_autoplay.gd | 2 +- game/{smoke => sim}/level_autoplay.gd.uid | 0 game/{smoke => sim}/monte_carlo.gd | 4 +- game/{smoke => sim}/monte_carlo.gd.uid | 0 game/{smoke => sim}/reward_expectation.gd | 2 +- game/{smoke => sim}/reward_expectation.gd.uid | 0 game/{smoke => sim}/rhythm_batch.gd | 4 +- game/{smoke => sim}/rhythm_batch.gd.uid | 0 game/{smoke => sim}/skill_rhythm_probe.gd | 4 +- game/{smoke => sim}/skill_rhythm_probe.gd.uid | 0 game/{smoke => sim}/verifier_batch.gd | 4 +- game/{smoke => sim}/verifier_batch.gd.uid | 0 game/smoke/smoke_all.gd | 7165 ----------------- game/smoke/smoke_all.gd.uid | 1 - game/tests/README.md | 16 +- .../integration/cross_system_flow_test.gd | 2 +- game/tests/static_scan.sh | 2 +- game/tests/system_test.gd | 6 +- .../battle_settlement/roll_multiplier_test.gd | 2 +- .../round_flow_skill_pool_test.gd | 2 +- .../battle_settlement/seal_polarity_test.gd | 2 +- game/tests/unit/core/adventure_feats_test.gd | 4 +- .../unit/core/level_shop_economy_test.gd | 10 +- .../unit/core/meta_progression_forge_test.gd | 2 +- .../unit/presentation/pacing_queue_test.gd | 2 +- .../presentation/theme_projection_test.gd | 2 +- sim-data/README.md | 2 +- sim-data/analyze.py | 2 +- tools/audit_type_hints.py | 2 +- tools/balance_verifier.py | 4 +- tools/clear_dominance_audit.py | 2 +- tools/golden_baseline_refresh.sh | 4 +- tools/parity_anchor.py | 2 +- tools/probe_gate.sh | 20 +- tools/render_gate.sh | 6 +- tools/repo_structure_audit.py | 2 +- tools/run_all_gates.sh | 2 +- tools/t3_lever_scan.py | 2 +- 体验验证清单.md | 2 +- 待办清单.md | 4 +- 110 files changed, 139 insertions(+), 7305 deletions(-) rename game/{smoke => probe}/board_shot_probe.gd (98%) rename game/{smoke => probe}/board_shot_probe.gd.uid (100%) rename game/{smoke => probe}/board_shot_probe.tscn (63%) rename game/{smoke => probe}/fx_probe.gd (99%) rename game/{smoke => probe}/fx_probe.gd.uid (100%) rename game/{smoke => probe}/fx_probe.tscn (60%) rename game/{smoke => probe}/fx_shot_probe.gd (97%) rename game/{smoke => probe}/fx_shot_probe.gd.uid (100%) rename game/{smoke => probe}/fx_shot_probe.tscn (62%) rename game/{smoke => probe}/layout_probe.gd (97%) rename game/{smoke => probe}/layout_probe.gd.uid (100%) rename game/{smoke => probe}/layout_probe.tscn (54%) rename game/{smoke => probe}/probe_ui_util.gd (99%) rename game/{smoke => probe}/probe_ui_util.gd.uid (100%) rename game/{smoke => probe}/screen_flow_probe.gd (98%) rename game/{smoke => probe}/screen_flow_probe.gd.uid (100%) rename game/{smoke => probe}/screen_flow_probe.tscn (64%) rename game/{smoke => probe}/shot_probe.gd (98%) rename game/{smoke => probe}/shot_probe.gd.uid (100%) rename game/{smoke => probe}/shot_probe.tscn (61%) rename game/{smoke => probe}/t17_defer_probe.gd (97%) rename game/{smoke => probe}/t17_defer_probe.gd.uid (100%) rename game/{smoke => probe}/t17_defer_probe.tscn (63%) rename game/{smoke => probe}/type_glow_shot_probe.gd (98%) rename game/{smoke => probe}/type_glow_shot_probe.gd.uid (100%) rename game/{smoke => probe}/type_glow_shot_probe.tscn (65%) rename game/{smoke => probe}/ui_click_probe.gd (99%) rename game/{smoke => probe}/ui_click_probe.gd.uid (100%) rename game/{smoke => probe}/ui_click_probe.tscn (63%) rename game/{smoke => probe}/ui_contrast_probe.gd (99%) rename game/{smoke => probe}/ui_contrast_probe.gd.uid (100%) rename game/{smoke => probe}/ui_contrast_probe.tscn (64%) rename game/{smoke => probe}/ui_input_probe.gd (99%) rename game/{smoke => probe}/ui_input_probe.gd.uid (100%) rename game/{smoke => probe}/ui_input_probe.tscn (63%) rename game/{smoke => probe}/ui_probe.gd (99%) rename game/{smoke => probe}/ui_probe.gd.uid (100%) rename game/{smoke => probe}/ui_probe.tscn (60%) rename game/{smoke => probe}/ui_struct_probe.gd (99%) rename game/{smoke => probe}/ui_struct_probe.gd.uid (100%) rename game/{smoke => probe}/ui_struct_probe.tscn (63%) rename game/{smoke => probe}/ui_tune_probe.gd (99%) rename game/{smoke => probe}/ui_tune_probe.gd.uid (100%) rename game/{smoke => probe}/ui_tune_probe.tscn (62%) rename game/{smoke => sim}/adventure_monte_carlo.gd (98%) rename game/{smoke => sim}/adventure_monte_carlo.gd.uid (100%) rename game/{smoke => sim}/auto_player.gd (100%) rename game/{smoke => sim}/auto_player.gd.uid (100%) rename game/{smoke => sim}/battle_driver.gd (97%) rename game/{smoke => sim}/battle_driver.gd.uid (100%) rename game/{smoke => sim}/board_stat_probe.gd (98%) rename game/{smoke => sim}/board_stat_probe.gd.uid (100%) rename game/{smoke => sim}/calibrate_boards.gd (99%) rename game/{smoke => sim}/calibrate_boards.gd.uid (100%) rename game/{smoke => sim}/damage_probe.gd (98%) rename game/{smoke => sim}/damage_probe.gd.uid (100%) rename game/{smoke => sim}/defense_scan.gd (97%) rename game/{smoke => sim}/defense_scan.gd.uid (100%) rename game/{smoke => sim}/dump_battle.gd (92%) rename game/{smoke => sim}/dump_battle.gd.uid (100%) rename game/{smoke => sim}/experiment_density.gd (100%) rename game/{smoke => sim}/experiment_density.gd.uid (100%) rename game/{smoke => sim}/level_autoplay.gd (99%) rename game/{smoke => sim}/level_autoplay.gd.uid (100%) rename game/{smoke => sim}/monte_carlo.gd (96%) rename game/{smoke => sim}/monte_carlo.gd.uid (100%) rename game/{smoke => sim}/reward_expectation.gd (99%) rename game/{smoke => sim}/reward_expectation.gd.uid (100%) rename game/{smoke => sim}/rhythm_batch.gd (94%) rename game/{smoke => sim}/rhythm_batch.gd.uid (100%) rename game/{smoke => sim}/skill_rhythm_probe.gd (93%) rename game/{smoke => sim}/skill_rhythm_probe.gd.uid (100%) rename game/{smoke => sim}/verifier_batch.gd (99%) rename game/{smoke => sim}/verifier_batch.gd.uid (100%) delete mode 100644 game/smoke/smoke_all.gd delete mode 100644 game/smoke/smoke_all.gd.uid diff --git a/AGENTS.md b/AGENTS.md index 813daa7a..265f8dbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ ## 运行与验证(工作目录 = 含 project.godot 的切片目录) - **交付前验收 = 跑 `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/`) +- 单件直跑(调试期最窄确认用,**不构成验收**):冒烟测试 `python tools/run_smoke.py`(`game/tests/smoke_boot.tscn` 极小启动场景)· 系统测试 `python tools/run_system_test.py`(宿主 `game/tests/system_test.gd`)· 场景探针 `…console.exe --headless --path . res://probe/ui_probe.tscn` · 单元与集成 `… --script res://tests/run_all.gd` +- 调参批量:`--script res://sim/adventure_monte_carlo.gd`(全冒险期望)· `res://sim/reward_expectation.gd`(奖励期望);通关率/难度类测量一律走全冒险 +- 数值验证:`python tools/balance_verifier.py`(采集端 `res://sim/verifier_batch.gd`,报告与原始数据落 `sim-data/`) - 熵增闸(新类别 = 同批修订 Project Layout + 白名单):`python tools/repo_structure_audit.py` ## Project Layout @@ -30,7 +30,7 @@ - `design/content-archive/` — 退役内容留底(活文档,2026-10-20 用户裁新立:内容已退役、留底供日后重新过准入;落内文件头部写明所属线) - `design/research/` **已退役(2026-10-21 用户裁)**——`design/` 全目录只放持久化设计文档与规范,调研/提案/评估/历史验收一律落 `reports/`(熵增闸退役路径清单已收该目录,再现即判红);`design/qa/` 已退役,验证截图不进 `design/`(判据见 doc-conventions §11) - `artwork/` — 在途图像素材唯一工作区(文档归文档/在途图归一处/终稿归游戏);整目录不入 git(.gitignore 强制),收口归档 `archive/` -- `game/assets/art/` — 已验收终稿(入包被游戏加载);`game/` — 游戏工程本体(代码 + smoke/ 冒烟 + tests/ 单元集成) +- `game/assets/art/` — 已验收终稿(入包被游戏加载);`game/probe/` — 场景探针的场景与宿主+探针共用面(probe_gate/render_gate 在闸+人工取证探针);`game/sim/` — 测量/诊断/模拟驱动件(autoplay 策略、MC 批量、数值验证采集端);`game/` — 游戏工程本体(代码 + tests/ 冒烟/单元/集成/系统测试面) - `sim-data/` — 机器读数据产物;数据件不入 git(入库例外 = README+analyze.py+growth_model.py);MC 锚与读数常驻留盘,无数值调整禁重跑 - `tools/` — python 工具脚本;`archive/` — 批次完结归档 zip(gitignored) - `reports/` — 过程件容器:**需求卡片**(写卡段产出,实现批的输入合同)+迭代批产出物(实现报告/代拍板追认单/GDD 反哺点清单)+设计讨论段过程稿(提案/审计/需求澄清/评估);**实现批唯一可写目录**,中间文档唯一容器(`design/research/` 已退役),不是事实源(细则见 `reports/README.md`) diff --git a/design/engineering/architecture/architecture.md b/design/engineering/architecture/architecture.md index 6423d481..6eb4fbce 100644 --- a/design/engineering/architecture/architecture.md +++ b/design/engineering/architecture/architecture.md @@ -186,7 +186,7 @@ | 模块 | 载体 | 独占职责 | 对外暴露 | 判定口径 | |---|---|---|---|---| | **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_system_test**(系统测试运行器) | `tools/run_system_test.py` | 系统测试总入口:headless 跑真实玩法路径断言面(宿主=`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 容差与取证文件存在性,非断言计数) | @@ -238,8 +238,8 @@ | 冒烟测试 | 这版活着吗(零逻辑判定) | `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) | +| 系统测试 | 真实玩法路径断言面 | `game/tests/system_test.gd` | `run_system_test.py`(闸 01) | +| UI 探针+渲染 golden | 场景切换与观感 | `game/probe/*.tscn` 探针 | `probe_gate.sh`(闸 03/04)、`render_gate.sh`(闸 05) | **判定原则**:验收不能只是「跑一遍没崩」。三硬闸(退出码/脚本错误扫描/断言数下限)是判定的底线, 缺一即判定有洞;「日志里写着 PASS」不作判据。另有两类静默失效必须机制性防范——规则扫描面为空 @@ -312,7 +312,7 @@ CJK 字体随包发行并设为 theme 默认字体——运行时默认字体无 → 关卡末:通关结算(钥石/GP/专长 level_clear 渠道——现行语义归 冒险结构 GDD;原 clear_inventory 随库存轴下线退役 2026-09-15) ``` -**启动后置条件**(2026-10-17 用户裁入册——迭代 28 以冒烟 V2a–V2f 共 11 条断言把「启动完成后玩家该看到什么」成文;执行判据事实源在 `game/smoke/smoke_all.gd`): +**启动后置条件**(2026-10-17 用户裁入册——迭代 28 以冒烟 V2a–V2f 共 11 条断言把「启动完成后玩家该看到什么」成文;执行判据事实源在 `game/tests/system_test.gd`): 1. ConfigDB 就绪且表已载(`is_ready` + hero 表在座)。 2. 系统设置已落引擎态——Master 音量 mute/volume_db = settings_store 现值;减动效开关 = `UiKit.motion_scale`。 diff --git a/design/engineering/code-conventions.md b/design/engineering/code-conventions.md index 0d4b8fe0..129e5771 100644 --- a/design/engineering/code-conventions.md +++ b/design/engineering/code-conventions.md @@ -64,7 +64,7 @@ 4. **lambda 捕获循环变量按创建时值**——循环内先落地局部变量再捕获,或用 `bind`。 5. **重命名脚本必须同步 `.uid` 文件**;新增 `class_name` 后必须跑一次编辑器扫描刷新全局类缓存,否则 `--headless --script` 模式解析失败。 6. **`print` 格式占位符与实参类型必须匹配**(`%d` 配 String = 静默输出字面量)。 -7. **语言纪律已机器化为 S9 类型闸**——`tools/audit_type_hints.py`,执行载体=静态扫描域 S9(`game/tests/static_scan.sh`),退出码 0=归零。扫描面=产品码(`game/core`+`game/data`+`game/presentation`;smoke/tests 豁免——测量面容器直构是既有惯例)。三规则:R1 无类型容器(显式非参数化 `Dictionary`/`Array` 注解)/R2 有值返回缺返回类型(纯副作用缺 `-> void` 属写法面不入闸)/R3 参数缺类型(`_init` 豁免——构造注入面形态自由)。 +7. **语言纪律已机器化为 S9 类型闸**——`tools/audit_type_hints.py`,执行载体=静态扫描域 S9(`game/tests/static_scan.sh`),退出码 0=归零。扫描面=产品码(`game/core`+`game/data`+`game/presentation`;probe/sim/tests 不在扫描面——测量面容器直构是既有惯例)。三规则:R1 无类型容器(显式非参数化 `Dictionary`/`Array` 注解)/R2 有值返回缺返回类型(纯副作用缺 `-> void` 属写法面不入闸)/R3 参数缺类型(`_init` 豁免——构造注入面形态自由)。 8. **`:= {}` 推断形态不算债**——`var x := {}` 类赋值推断属类型推断合法面,不入 R1 扫描;只有显式写的非参数化容器注解才算无类型容器。 9. **行级白名单通道 `# type-hint: ignore`**——豁免该行不入 S9;豁免理由必须随行注明(现行例:`board_generator` 四函数的 db 参鸭子型——typed 参拒冒烟夹具 `_BoardRowStub` 注入)。 10. **`# rng-seed-exempt` 标记豁免通道**——S1/S2 随机函数扫描对该行豁免;限定用途=非玩法 RNG 消费面的引擎熵(现行例:组合根种子编辑框空态取 `randi()`——建种子不进玩法封闭流)。 diff --git a/design/engineering/test-conventions.md b/design/engineering/test-conventions.md index 8ee9a53f..a2839595 100644 --- a/design/engineering/test-conventions.md +++ b/design/engineering/test-conventions.md @@ -1,6 +1,6 @@ # 测试规范(Dungeon Sweeper) -> 约束范围:`game/smoke/`、`game/tests/` 与一切场景探针的编写与验收。 +> 约束范围:`game/probe/ 与 game/sim/`、`game/tests/` 与一切场景探针的编写与验收。 > 分工:测试**执行许可**(什么时候允许跑测试)归 AGENTS.md「硬性红线·测试执行两入口铁规」;**跑什么闸、什么顺序**归 `tools/run_all_gates.sh`(脚本即清单,本文与 AGENTS.md 均不另列);本文管测试怎么写、怎么算合格。 **写任何测试前必须通读本文。** @@ -33,7 +33,7 @@ 需求卡片 §8「改动—验收对照表」每一条都要落成可执行的验证;本文定统一写法,卡里定每批的具体内容(测什么/注入哪里)。写卡期查不出执行载体的条目=不可测,当场呈报,禁拖到实现期。 -1. **执行载体五选一,逐条声明**:单元测试(`tests/run_all`)/冒烟断言(`smoke_all.gd` 对应场景段)/场景探针(`smoke/` 下 `*_probe`)/MC 同种子逐项比对/人工核对(仅限无法自动化的纪律类需求点,如命名锁定、数值占位)。行为类条目以自动化载体为主。 +1. **执行载体五选一,逐条声明**:单元测试(`tests/run_all`)/系统测试断言(`tests/system_test.gd`)/场景探针(`probe/` 下 `*_probe`)/MC 同种子逐项比对/人工核对(仅限无法自动化的纪律类需求点,如命名锁定、数值占位)。行为类条目以自动化载体为主。 2. **粒度与对应关系**:每条清单条目至少一个自动断言,且断言必须从该条目的真实入口进入;多条条目可合用一个测试函数,但撤销对应改动时每条都要能单独变红。断言失败消息末尾带编号尾缀〔卡短名 Vn〕(如〔秘宝批 V3〕)——断言与卡片清单可互相 grep 查找。 3. **断言词汇按验证对象五选一**: @@ -78,7 +78,7 @@ - 负面套件(测试目标就是产生错误输出)仅可豁免第 2 闸,且须在 manifest 显式标记,其余两闸照判。 - 子进程输出须经系统命令解释器重定向落盘再读——`OS.execute` 的输出管道在本机恒空。 -**新写测试件全类型标注**——新写的测试件(冒烟断言/探针/单测)参数、返回值、容器一律带类型标注,与 S9 对产品码的要求同型;存量不强制清扫,被批次触碰时顺带换代。暂不机器闸(S9 扫描面=产品码,smoke/tests 豁免是测量面直构惯例的既有裁量)。 +**新写测试件全类型标注**——新写的测试件(冒烟断言/探针/单测)参数、返回值、容器一律带类型标注,与 S9 对产品码的要求同型;存量不强制清扫,被批次触碰时顺带换代。暂不机器闸(S9 扫描面=产品码,probe/sim/tests 不在扫描面是测量面直构惯例的既有裁量)。 ## 8. 判定可靠性优先于覆盖率(迭代 29 核心命题) diff --git a/design/gdd/systems/表现与支撑/数据配置.md b/design/gdd/systems/表现与支撑/数据配置.md index 13ba1788..16ebf631 100644 --- a/design/gdd/systems/表现与支撑/数据配置.md +++ b/design/gdd/systems/表现与支撑/数据配置.md @@ -92,7 +92,7 @@ 生命周期:进程启动后流待命(未注入主种子就取数,致命中止,禁止默认种子兜底)→ 冒险开始注入主种子并开日志会话 → 冒险中按事件取号(全局计数器只增不重置)→ 冒险结束流保留供复盘。续玩与复盘的快照 = 主种子加全局取数计数器,由[存档序列化](存档序列化.md)消费;快照切出须发生在没有在用取号实例的时刻。要新增随机用途(含战斗内随机),走扩签通道:改代码加用途标签并通过评审——词表封闭是刻意的设卡。 -举证:派生公式与实现规约见 `game/core/rng_service.gd`(盐与派生常量为代码事实),黄金向量断言见 `game/smoke/smoke_all.gd`;本文不转载。 +举证:派生公式与实现规约见 `game/core/rng_service.gd`(盐与派生常量为代码事实),黄金向量断言见 `game/tests/unit/data_config/rng_stream_boot_test.gd`;本文不转载。 ### fail-fast 校验:宁可启动失败,不带病运行 diff --git a/design/gdd/术语表.md b/design/gdd/术语表.md index 202d3e0c..b4ea59a7 100644 --- a/design/gdd/术语表.md +++ b/design/gdd/术语表.md @@ -126,7 +126,7 @@ 1. **历史文本**:git 提交说明、裁决记录中引用用户原话的句子——保留原用词,不按 A 表追改。 2. **对照性文本**:「退役词对照」表本身、「原 ××」式更名记载、禁令引举句——旧词在这些位置是内容本身,不是残留。 3. **玩家可见专名与机制词**:按「对外命名纪律」节与各系统文档现行用语使用(标中/踩雷等机制词在文档内部照常用)。 -4. **被检查的禁字不算使用**:守卫代码里作为检查对象的禁字字面量——`game/smoke/smoke_all.gd` 的 `contains("旗")` 检查项、`game/tests/unit/presentation/ui_font_asset_test.gd` 的字形集负探针、`game/data/tables/feats.tres` 头注的「desc 禁『雷/旗』字面」、`tools/doc_term_audit.py` 的退役词表定义行——不属换代范围:被检查的字不是对该字的使用,改写等于自毁守卫。术语清扫的复扫口径按 **game/ 全部文件类型、不排除任何扩展名**执行(迭代 31 卡① 实测:金币/插旗/雷格/惩罚格/`flag` 均 0 命中,残留仅为上述保留面)。 +4. **被检查的禁字不算使用**:守卫代码里作为检查对象的禁字字面量——`game/tests/unit/core/adventure_feats_test.gd` 的 `contains("旗")` 检查项、`game/tests/unit/presentation/ui_font_asset_test.gd` 的字形集负探针、`game/data/tables/feats.tres` 头注的「desc 禁『雷/旗』字面」、`tools/doc_term_audit.py` 的退役词表定义行——不属换代范围:被检查的字不是对该字的使用,改写等于自毁守卫。术语清扫的复扫口径按 **game/ 全部文件类型、不排除任何扩展名**执行(迭代 31 卡① 实测:金币/插旗/雷格/惩罚格/`flag` 均 0 命中,残留仅为上述保留面)。 存量口径:A 表生效前已入库的正文不做强制清扫;文件后续被批次触碰时,顺带按 A 表换代。 diff --git a/game/README.md b/game/README.md index 6791086e..a997e1e4 100644 --- a/game/README.md +++ b/game/README.md @@ -5,7 +5,7 @@ > // "数值提升→通关期望改善"的 Roguelike 元进度验证——基线属性应该难到打不过、天赋数值提升把 > // 通关期望拉回可玩带,且这条曲线能用数值调整板 + 批量模拟**测量出来** > // (通关攻击期望 / 败北血量数学期望);v1 的单战斗循环问题(逐行归因 P2 / Core headless 可跑) -> // 已于 2026-09-10 验证并保持回归(smoke_all 前 46 项)。 +> // 已于 2026-09-10 验证并保持回归(初版断言面 46 项,后并迁系统测试宿主,沿革查 git log)。 > // Date: 2026-09-10(v1)· 2026-09-11(v2 关卡扩展)· 引擎 Godot 4.7.2 · 本目录仅作设计验证参考,禁止 import 进生产代码 ## 运行 @@ -18,27 +18,27 @@ headless 冒烟(752 项断言〔计数随批增长,现值以最新批末闸读数为准〕,含关卡线性推进/满配通关/裸装败北/回放确定性;2026-10-02 性能报告实测 60.55 秒,报告在 `性能实测报告(已退役,git 历史可查)`): ``` -Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/smoke_all.gd +Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://probe/smoke_all.gd ``` 场景级 UI 探针(项目运行模式实例化 main.tscn 驱动第一关,断言棋盘真实渲染——D1 教训"呈现层零测试承载"的原型兑现): ``` -Godot_v4.7.2-stable_win64_console.exe --headless --path . res://smoke/ui_probe.tscn +Godot_v4.7.2-stable_win64_console.exe --headless --path . res://probe/ui_probe.tscn ``` 调试浮窗场景探针(缺陷⑤回归载体沿袭——2026-10-04 迭代 14.5 随批改接浮窗形态:打开可见性〔浮于菜单之上,开始层不隐藏〕/滑条联动/应用写表/重置/批量模拟全链路): ``` -Godot_v4.7.2-stable_win64_console.exe --headless --path . res://smoke/ui_tune_probe.tscn +Godot_v4.7.2-stable_win64_console.exe --headless --path . res://probe/ui_tune_probe.tscn ``` -其余八场景探针(ui_click_probe 行为链 / ui_input_probe 输入合成 / ui_struct_probe 全屏结构 / ui_contrast_probe 对比度 / ui_probe·ui_tune_probe 调参面板 / t17_defer_probe 结算演播延迟链回归 / shot_probe·fx_probe·fx_shot_probe·board_shot_probe 画面与特效采样;全部由 `tools/probe_gate.sh` 与 `tools/render_gate.sh` 调度,闸清单见 `tools/run_all_gates.sh`)+ 板图校准工具 `smoke/calibrate_boards.gd`(board_calibration.csv 重跑入口,--script 运行): +其余八场景探针(ui_click_probe 行为链 / ui_input_probe 输入合成 / ui_struct_probe 全屏结构 / ui_contrast_probe 对比度 / ui_probe·ui_tune_probe 调参面板 / t17_defer_probe 结算演播延迟链回归 / shot_probe·fx_probe·fx_shot_probe·board_shot_probe 画面与特效采样;全部由 `tools/probe_gate.sh` 与 `tools/render_gate.sh` 调度,闸清单见 `tools/run_all_gates.sh`)+ 板图校准工具 `sim/calibrate_boards.gd`(board_calibration.csv 重跑入口,--script 运行): 全冒险期望扫描(零加成三关连探批量——通关率/止步分布/败退节点;调参期难度读数一律走全冒险口径): ``` -Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/adventure_monte_carlo.gd -- [N] [seed0] +Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://sim/adventure_monte_carlo.gd -- [N] [seed0] ``` (原关卡期望扫描 level_monte_carlo.gd 走单关直入口口径——满血零上下文进关与真实冒险脱节,随口径退役删除,2026-09-23 用户拍板。) @@ -47,7 +47,7 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/a **日志合规(2026-09-12 用户要求)**:任何模拟运行与真人试玩均落全量 JSONL(user://logs/)——逐操作(op/cell/accepted/round)、逐结算明细行、轮次状态快照、battle_end、level_end。模拟(verifier/adventure_monte_carlo / 调试浮窗批量模拟)一文件多局,run 行携带种子/档位切分局次。 -**试玩画像口径(2026-09-19 用户拍板固化——全项目试玩/扫描/面板模拟一律只用以下两档,随机策略口径退役)**:`policy="normal"` = **普通画像·基础定式**(单数字满足规则——满足即标/满足即开,无处可推盲挖边界格、不刷新;教学后正常玩家的下限近似);`policy="perfect"` = **完美画像**(single+subset 全推理零盲挖/卡死刷新——技能上界仪)。实现单一出口 = `smoke/auto_player.gd`(novice_action / perfect_action)经 `smoke/level_autoplay.gd`,调试浮窗批量模拟与各扫描工具自动同口径;战斗层冒烟夹具与调试转储的 `choose_dig` 直调是机制测试夹具、不承载画像语义。分级依据与新旧读数(乱点 0%/5% → 基础定式 65%/90%)见 auto_player.gd 普通策略档节与台账 77。 +**试玩画像口径(2026-09-19 用户拍板固化——全项目试玩/扫描/面板模拟一律只用以下两档,随机策略口径退役)**:`policy="normal"` = **普通画像·基础定式**(单数字满足规则——满足即标/满足即开,无处可推盲挖边界格、不刷新;教学后正常玩家的下限近似);`policy="perfect"` = **完美画像**(single+subset 全推理零盲挖/卡死刷新——技能上界仪)。实现单一出口 = `sim/auto_player.gd`(novice_action / perfect_action)经 `sim/level_autoplay.gd`,调试浮窗批量模拟与各扫描工具自动同口径;战斗层冒烟夹具与调试转储的 `choose_dig` 直调是机制测试夹具、不承载画像语义。分级依据与新旧读数(乱点 0%/5% → 基础定式 65%/90%)见 auto_player.gd 普通策略档节与台账 77。 单关诊断工具(level_debug.gd)随单关直入口口径退役(2026-09-23)——败退归因走 balance_verifier 冒险段(层/节点/敌人人读归因)。 战斗级工具(v1 遗产):`dump_battle.gd`(结算流水)/ `monte_carlo.gd`(战斗期望)/ `experiment_density.gd`(密度调参)。 diff --git a/game/presentation/tune_sim_controller.gd b/game/presentation/tune_sim_controller.gd index 12e52666..ccb3b0d8 100644 --- a/game/presentation/tune_sim_controller.gd +++ b/game/presentation/tune_sim_controller.gd @@ -24,7 +24,7 @@ func _init(db_ref, panel_ref, base_hp: int, driver = null) -> void: func _autoplay(): if _driver != null: return _driver - return load("res://smoke/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3 兜底路径) + return load("res://sim/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3 兜底路径) func run(n: int) -> void: diff --git a/game/smoke/board_shot_probe.gd b/game/probe/board_shot_probe.gd similarity index 98% rename from game/smoke/board_shot_probe.gd rename to game/probe/board_shot_probe.gd index 0080a3f4..b5c7bb67 100644 --- a/game/smoke/board_shot_probe.gd +++ b/game/probe/board_shot_probe.gd @@ -1,7 +1,7 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # 棋盘渲染帧取证探针(目视验证面:机检≠看得清)。 # 必须**窗口化**运行(headless 无渲染面): -# Godot_console.exe --path . res://smoke/board_shot_probe.tscn -- <输出目录> +# Godot_console.exe --path . res://probe/board_shot_probe.tscn -- <输出目录> # 流程:L1/L2/L3 各起一板(真实 LevelRunner+settle_batch 驱动 ~18 步 novice——自然揭示数字/封印), # BoardView 真渲染,frame_post_draw 同步后抓视口 PNG。零存档触碰(无 save_manager 引用)。 # 护栏:headless fail-fast(shot_probe 同款);空纹理取证 fail——取证段失败=闸红, @@ -14,7 +14,7 @@ # 前置 UiKit.motion_scale=0.5 冻结接触带呼吸相位(静态带色),两帧差异唯一定位在类型微光。 extends Node -const AutoPlayer := preload("res://smoke/auto_player.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") const ActionEconomy := preload("res://core/action_economy.gd") var _meta := {} # 棋盘视图实测几何(全局 rect——tools/golden_board_crop.py 取样坐标源) diff --git a/game/smoke/board_shot_probe.gd.uid b/game/probe/board_shot_probe.gd.uid similarity index 100% rename from game/smoke/board_shot_probe.gd.uid rename to game/probe/board_shot_probe.gd.uid diff --git a/game/smoke/board_shot_probe.tscn b/game/probe/board_shot_probe.tscn similarity index 63% rename from game/smoke/board_shot_probe.tscn rename to game/probe/board_shot_probe.tscn index 447adae5..1985ff34 100644 --- a/game/smoke/board_shot_probe.tscn +++ b/game/probe/board_shot_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/board_shot_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/board_shot_probe.gd" id="1"] [node name="BoardShotProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/fx_probe.gd b/game/probe/fx_probe.gd similarity index 99% rename from game/smoke/fx_probe.gd rename to game/probe/fx_probe.gd index 18ca7cac..cacd9a4f 100644 --- a/game/smoke/fx_probe.gd +++ b/game/probe/fx_probe.gd @@ -3,7 +3,7 @@ # ①死亡碎裂(standee mark_dead → 碎块计数)②意图三档动画(damage/buff/debuff/charge 摇定不炸+徽标可见) # ③胜利纹章+失败掸灰+封印光收回(battle_end open_victory/open_defeat → fx_probe 计数) # ④结算轮光带(settlement_fx play_round_sweep → 子节点落位)。 -# 运行:Godot_console.exe --headless --path . res://smoke/fx_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/fx_probe.tscn extends Node diff --git a/game/smoke/fx_probe.gd.uid b/game/probe/fx_probe.gd.uid similarity index 100% rename from game/smoke/fx_probe.gd.uid rename to game/probe/fx_probe.gd.uid diff --git a/game/smoke/fx_probe.tscn b/game/probe/fx_probe.tscn similarity index 60% rename from game/smoke/fx_probe.tscn rename to game/probe/fx_probe.tscn index b52cc316..d76f3724 100644 --- a/game/smoke/fx_probe.tscn +++ b/game/probe/fx_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/fx_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/fx_probe.gd" id="1"] [node name="FxProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/fx_shot_probe.gd b/game/probe/fx_shot_probe.gd similarity index 97% rename from game/smoke/fx_shot_probe.gd rename to game/probe/fx_shot_probe.gd index 2f40d24a..9ba6d96d 100644 --- a/game/smoke/fx_shot_probe.gd +++ b/game/probe/fx_shot_probe.gd @@ -1,12 +1,12 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # 演出渲染证据探针(真实渲染设备抓战斗演出瞬间帧)。 -# 必须**窗口化**运行(真渲染设备——headless 无渲染面):Godot_console.exe --path . res://smoke/fx_shot_probe.tscn -- <输出目录> +# 必须**窗口化**运行(真渲染设备——headless 无渲染面):Godot_console.exe --path . res://probe/fx_shot_probe.tscn -- <输出目录> # 流程 = shot_probe 同款隔离面(存档/设置/元进度换注隔离档,禁碰真实档)+ 真实按钮路径开冒险进首战; # 自动玩家挖格驱动行结算,在 行到达/弹道飞行/结算排播 三时刻抓帧——演出链「逻辑在(fx_probe PASS)× # 渲染在(本探针 PNG)」双证的第二半边。PNG 落输出目录(artwork/qa-fx-*/,素材纪律不入 git)。 extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 # ── 演出采样窗口口径(headless/窗口化帧率不定,帧时刻以墙钟近似锚定;改窗口 = 改抓帧 # 时刻观测面,非断言语义。窗口值与字面量同值——仅提为具名常量。) ── diff --git a/game/smoke/fx_shot_probe.gd.uid b/game/probe/fx_shot_probe.gd.uid similarity index 100% rename from game/smoke/fx_shot_probe.gd.uid rename to game/probe/fx_shot_probe.gd.uid diff --git a/game/smoke/fx_shot_probe.tscn b/game/probe/fx_shot_probe.tscn similarity index 62% rename from game/smoke/fx_shot_probe.tscn rename to game/probe/fx_shot_probe.tscn index caf38f16..accfbd24 100644 --- a/game/smoke/fx_shot_probe.tscn +++ b/game/probe/fx_shot_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/fx_shot_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/fx_shot_probe.gd" id="1"] [node name="FxShotProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/layout_probe.gd b/game/probe/layout_probe.gd similarity index 97% rename from game/smoke/layout_probe.gd rename to game/probe/layout_probe.gd index f354b53f..f16c8573 100644 --- a/game/smoke/layout_probe.gd +++ b/game/probe/layout_probe.gd @@ -5,7 +5,7 @@ # 硬约束:档位表是数据、断言是一份函数据——加档只改表不动断言(禁每档建一个探针) extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # 隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # 隔离样板单点 # 分辨率档位表(逻辑视口尺寸;数据侧——增删档位只改这里,断言代码零改动) const VIEWPORTS := [ diff --git a/game/smoke/layout_probe.gd.uid b/game/probe/layout_probe.gd.uid similarity index 100% rename from game/smoke/layout_probe.gd.uid rename to game/probe/layout_probe.gd.uid diff --git a/game/smoke/layout_probe.tscn b/game/probe/layout_probe.tscn similarity index 54% rename from game/smoke/layout_probe.tscn rename to game/probe/layout_probe.tscn index 3d10564e..00e52e44 100644 --- a/game/smoke/layout_probe.tscn +++ b/game/probe/layout_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3 uid="uid://clayoutprobe01"] -[ext_resource type="Script" path="res://smoke/layout_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/layout_probe.gd" id="1"] [node name="LayoutProbe" type="Node"] script = ExtResource("1") \ No newline at end of file diff --git a/game/smoke/probe_ui_util.gd b/game/probe/probe_ui_util.gd similarity index 99% rename from game/smoke/probe_ui_util.gd rename to game/probe/probe_ui_util.gd index f6382886..f7ccfb68 100644 --- a/game/smoke/probe_ui_util.gd +++ b/game/probe/probe_ui_util.gd @@ -2,7 +2,7 @@ # 探针共用面:树寻址助手 + 存档/设置/元进度隔离样板单点。 # 覆盖:ui_probe / ui_click_probe / shot_probe / fx_shot_probe 四探针的树寻址样板 # 与隔离样板(各探针隔离文件名后缀 tag 保持不变)。 -# 用法:探针侧 `const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd")` 后静态调用 +# 用法:探针侧 `const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")` 后静态调用 #(探针载体含 --script 场景两种,均无全局标识符——禁 class_name 裸引用)。 extends RefCounted diff --git a/game/smoke/probe_ui_util.gd.uid b/game/probe/probe_ui_util.gd.uid similarity index 100% rename from game/smoke/probe_ui_util.gd.uid rename to game/probe/probe_ui_util.gd.uid diff --git a/game/smoke/screen_flow_probe.gd b/game/probe/screen_flow_probe.gd similarity index 98% rename from game/smoke/screen_flow_probe.gd rename to game/probe/screen_flow_probe.gd index ba4dda94..1ee71d1a 100644 --- a/game/smoke/screen_flow_probe.gd +++ b/game/probe/screen_flow_probe.gd @@ -4,10 +4,10 @@ # 与 ui_click_probe 的分工:那件走的是「点某个按钮有没有反应」的行为走查(含战斗推进与结算数值面); # 本件只走屏壳与流程状态,不推进战斗、不碰结算数值——屏级互斥是最容易被装配顺序改动悄悄破坏 # 又最难被单测覆盖的面(全库 change_scene 零命中,屏切换全靠 visible 互斥)。 -# 运行:Godot_console.exe --headless --path . res://smoke/screen_flow_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/screen_flow_probe.tscn extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") var main: Node var _problems: Array[String] = [] diff --git a/game/smoke/screen_flow_probe.gd.uid b/game/probe/screen_flow_probe.gd.uid similarity index 100% rename from game/smoke/screen_flow_probe.gd.uid rename to game/probe/screen_flow_probe.gd.uid diff --git a/game/smoke/screen_flow_probe.tscn b/game/probe/screen_flow_probe.tscn similarity index 64% rename from game/smoke/screen_flow_probe.tscn rename to game/probe/screen_flow_probe.tscn index 5d847fb3..aa30d18b 100644 --- a/game/smoke/screen_flow_probe.tscn +++ b/game/probe/screen_flow_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/screen_flow_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/screen_flow_probe.gd" id="1"] [node name="ScreenFlowProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/shot_probe.gd b/game/probe/shot_probe.gd similarity index 98% rename from game/smoke/shot_probe.gd rename to game/probe/shot_probe.gd index a098692b..c74a3e7f 100644 --- a/game/smoke/shot_probe.gd +++ b/game/probe/shot_probe.gd @@ -2,7 +2,7 @@ # 整屏截图探针(呈现层测试分级 B 档基建:视口截图程序化产出;七屏—— # 菜单/设置面/生涯页/营地/关卡画面/战斗/状态面板,现行事实源=tools/render_gate.sh)。 # 必须**窗口化**运行(真渲染设备——headless 无渲染面,get_image 得到空纹理): -# Godot_console.exe --path . res://smoke/shot_probe.tscn -- <输出目录> +# Godot_console.exe --path . res://probe/shot_probe.tscn -- <输出目录> # 流程 = 真实按钮信号路径驱动(ui_click_probe 同款;存档/设置/元进度三面换注隔离档——禁碰真实档, # 坑册「断点写盘随 save_manager 引用走」教训同款覆盖):菜单(启动态)→ 设置面(settingsbtn # 真实信号路径)→ 生涯页(函数直调中间态——卡② mn10)→ 营地(进档开层新路径——mn9)→ 关卡画面(函数直调中间态→停点出图)→ @@ -14,7 +14,7 @@ # 到达不成立;合成投喂(ui_struct_probe 同款)破坏本探针真实路径口径 → 不纳屏。 extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 var main: Node var out_dir := "" diff --git a/game/smoke/shot_probe.gd.uid b/game/probe/shot_probe.gd.uid similarity index 100% rename from game/smoke/shot_probe.gd.uid rename to game/probe/shot_probe.gd.uid diff --git a/game/smoke/shot_probe.tscn b/game/probe/shot_probe.tscn similarity index 61% rename from game/smoke/shot_probe.tscn rename to game/probe/shot_probe.tscn index bec4d4e3..f708f432 100644 --- a/game/smoke/shot_probe.tscn +++ b/game/probe/shot_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/shot_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/shot_probe.gd" id="1"] [node name="ShotProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/t17_defer_probe.gd b/game/probe/t17_defer_probe.gd similarity index 97% rename from game/smoke/t17_defer_probe.gd rename to game/probe/t17_defer_probe.gd index 49d74f99..dde96ecc 100644 --- a/game/smoke/t17_defer_probe.gd +++ b/game/probe/t17_defer_probe.gd @@ -6,7 +6,7 @@ # 存档/设置/元进度三面隔离(isolate_save 现行事实源,meta 经 autoload 链同源),零真实档触碰。headless 可跑(不抓帧)。 extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # 隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # 隔离样板单点 var main var _problems: Array = [] @@ -50,7 +50,7 @@ func _ready() -> void: var settle_clock := "" while guard > 0 and String(main.run_scope.settlement.battle_result) == "": guard -= 1 - var plan = load("res://smoke/auto_player.gd").choose_dig(ms) + var plan = load("res://sim/auto_player.gd").choose_dig(ms) if plan.is_empty(): main.run_scope.settlement.settle_batch([{"action": "stop", "cell": Vector2i(-1, -1)}]) await get_tree().process_frame diff --git a/game/smoke/t17_defer_probe.gd.uid b/game/probe/t17_defer_probe.gd.uid similarity index 100% rename from game/smoke/t17_defer_probe.gd.uid rename to game/probe/t17_defer_probe.gd.uid diff --git a/game/smoke/t17_defer_probe.tscn b/game/probe/t17_defer_probe.tscn similarity index 63% rename from game/smoke/t17_defer_probe.tscn rename to game/probe/t17_defer_probe.tscn index 6ff68f84..b7a8306f 100644 --- a/game/smoke/t17_defer_probe.tscn +++ b/game/probe/t17_defer_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/t17_defer_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/t17_defer_probe.gd" id="1"] [node name="T17DeferProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/type_glow_shot_probe.gd b/game/probe/type_glow_shot_probe.gd similarity index 98% rename from game/smoke/type_glow_shot_probe.gd rename to game/probe/type_glow_shot_probe.gd index b86a4aeb..f7b9c385 100644 --- a/game/smoke/type_glow_shot_probe.gd +++ b/game/probe/type_glow_shot_probe.gd @@ -1,7 +1,7 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # 未开格类型微光渲染取证探针(像素侧——V4 禁侵/V5 不压破/V6 五组互辨)。 # 必须**窗口化**运行(headless 无渲染面,抓帧恒空): -# Godot_console.exe --path . res://smoke/type_glow_shot_probe.tscn -- <输出目录> +# Godot_console.exe --path . res://probe/type_glow_shot_probe.tscn -- <输出目录> # 流程:起一板(真实 LevelRunner + settle_batch 驱动 18 步 novice——自然揭示数字/封印)→ 注入夹具身份回调 # (九 cell_type 循环 ⇒ 五组同屏齐现)→ BoardView 真渲染: # 帧 A = 微光关(基线态)→ type_glow_off.png @@ -11,7 +11,7 @@ # 零存档触碰(无 save_manager 引用)。取证件落调用方给的目录(库外),不入 git。 extends Node -const AutoPlayer := preload("res://smoke/auto_player.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") # 夹具:九 cell_type 全值域(地图战斗资源九型枚举同源) const FIXTURE_TYPES := ["attack", "defense", "blast", "heal", "weaken", diff --git a/game/smoke/type_glow_shot_probe.gd.uid b/game/probe/type_glow_shot_probe.gd.uid similarity index 100% rename from game/smoke/type_glow_shot_probe.gd.uid rename to game/probe/type_glow_shot_probe.gd.uid diff --git a/game/smoke/type_glow_shot_probe.tscn b/game/probe/type_glow_shot_probe.tscn similarity index 65% rename from game/smoke/type_glow_shot_probe.tscn rename to game/probe/type_glow_shot_probe.tscn index 9eb5308e..9fb2cafb 100644 --- a/game/smoke/type_glow_shot_probe.tscn +++ b/game/probe/type_glow_shot_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/type_glow_shot_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/type_glow_shot_probe.gd" id="1"] [node name="TypeGlowShotProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/ui_click_probe.gd b/game/probe/ui_click_probe.gd similarity index 99% rename from game/smoke/ui_click_probe.gd rename to game/probe/ui_click_probe.gd index bf22f5fb..d64d845d 100644 --- a/game/smoke/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -6,11 +6,11 @@ # + 模态遮挡断言(缺陷⑤回归:图例/回看/终局 overlay 打开时上方遮挡层隐藏、输入锁定) # + 性能 AC:场景实例化到首格可点 ≤500ms 探针计时。 # 行为序列由 PROBE_SEED 决定(shell 传参);程序玩法数值不受影响(随机只用于探针策略)。 -# 运行:PROBE_SEED=7 Godot_console.exe --headless --path . res://smoke/ui_click_probe.tscn +# 运行:PROBE_SEED=7 Godot_console.exe --headless --path . res://probe/ui_click_probe.tscn extends Node -const AutoPlayer := preload("res://smoke/auto_player.gd") # O4:自动玩家策略单一实现 -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 +const AutoPlayer := preload("res://sim/auto_player.gd") # O4:自动玩家策略单一实现 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 var main: Node var _last_state := {} diff --git a/game/smoke/ui_click_probe.gd.uid b/game/probe/ui_click_probe.gd.uid similarity index 100% rename from game/smoke/ui_click_probe.gd.uid rename to game/probe/ui_click_probe.gd.uid diff --git a/game/smoke/ui_click_probe.tscn b/game/probe/ui_click_probe.tscn similarity index 63% rename from game/smoke/ui_click_probe.tscn rename to game/probe/ui_click_probe.tscn index f469a278..f57d34ce 100644 --- a/game/smoke/ui_click_probe.tscn +++ b/game/probe/ui_click_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/ui_click_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/ui_click_probe.gd" id="1"] [node name="UIClickProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/ui_contrast_probe.gd b/game/probe/ui_contrast_probe.gd similarity index 99% rename from game/smoke/ui_contrast_probe.gd rename to game/probe/ui_contrast_probe.gd index f5ef2088..c2cf240c 100644 --- a/game/smoke/ui_contrast_probe.gd +++ b/game/probe/ui_contrast_probe.gd @@ -3,7 +3,7 @@ # 断言面 = 文字墨对直背景 ≥4.5:1(art-bible §7.2 无区域例外;文字禁直压纹理底——垫牌后按牌色计)。 # 测点表:血数垫牌 / 意图徽标四墨(PANEL_DATA 垫牌)/ C区槽位两态墨 / RAP 标签 + 全站锚点组。 # 测点对一律取 UiKit 常量实时求值——代码侧换色即本闸红,禁只改注释不动值;α<1 墨先对背景混成再算。 -# 运行:Godot_console.exe --headless --path . res://smoke/ui_contrast_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/ui_contrast_probe.tscn extends Node const MIN_RATIO := 4.5 diff --git a/game/smoke/ui_contrast_probe.gd.uid b/game/probe/ui_contrast_probe.gd.uid similarity index 100% rename from game/smoke/ui_contrast_probe.gd.uid rename to game/probe/ui_contrast_probe.gd.uid diff --git a/game/smoke/ui_contrast_probe.tscn b/game/probe/ui_contrast_probe.tscn similarity index 64% rename from game/smoke/ui_contrast_probe.tscn rename to game/probe/ui_contrast_probe.tscn index 6f1282fd..f59fe365 100644 --- a/game/smoke/ui_contrast_probe.tscn +++ b/game/probe/ui_contrast_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/ui_contrast_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/ui_contrast_probe.gd" id="1"] [node name="UIContrastProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/ui_input_probe.gd b/game/probe/ui_input_probe.gd similarity index 99% rename from game/smoke/ui_input_probe.gd rename to game/probe/ui_input_probe.gd index 3b455342..cf7c8dea 100644 --- a/game/smoke/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -10,10 +10,10 @@ #(⑧–⑪=Node2D 展示几何/状态面单元化断言)。 # 事件路径:Viewport.push_input(ev, in_local_coords=true)——落点用 BoardView.cell_center_local # (与 _cell_at 同一公式族)换算画布坐标,SubViewportContainer mouse_filter=IGNORE 不拦截。 -# 运行:Godot_console.exe --headless --path . res://smoke/ui_input_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/ui_input_probe.tscn extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:隔离样板单点 var main: Node var _problems: Array = [] diff --git a/game/smoke/ui_input_probe.gd.uid b/game/probe/ui_input_probe.gd.uid similarity index 100% rename from game/smoke/ui_input_probe.gd.uid rename to game/probe/ui_input_probe.gd.uid diff --git a/game/smoke/ui_input_probe.tscn b/game/probe/ui_input_probe.tscn similarity index 63% rename from game/smoke/ui_input_probe.tscn rename to game/probe/ui_input_probe.tscn index 686310e0..ba5497cd 100644 --- a/game/smoke/ui_input_probe.tscn +++ b/game/probe/ui_input_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/ui_input_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/ui_input_probe.gd" id="1"] [node name="UIInputProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/ui_probe.gd b/game/probe/ui_probe.gd similarity index 99% rename from game/smoke/ui_probe.gd rename to game/probe/ui_probe.gd index eab8fbdf..26896b6d 100644 --- a/game/smoke/ui_probe.gd +++ b/game/probe/ui_probe.gd @@ -3,10 +3,10 @@ # 项目运行模式(autoload 在位)实例化 main.tscn,直接驱动 _start_adventure_from_menu()(完整冒险——唯一试玩入口), # 断言真实构建(棋盘/主角站桩/敌方站桩/AP 点阵/明细带/数据框;无 A 区进度条断言与 E 区道具面板; # 英雄/敌人 = 站桩断言)。 -# 运行:Godot_console.exe --headless --path . res://smoke/ui_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/ui_probe.tscn extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 func _ready() -> void: diff --git a/game/smoke/ui_probe.gd.uid b/game/probe/ui_probe.gd.uid similarity index 100% rename from game/smoke/ui_probe.gd.uid rename to game/probe/ui_probe.gd.uid diff --git a/game/smoke/ui_probe.tscn b/game/probe/ui_probe.tscn similarity index 60% rename from game/smoke/ui_probe.tscn rename to game/probe/ui_probe.tscn index c0e54d73..36e1fee7 100644 --- a/game/smoke/ui_probe.tscn +++ b/game/probe/ui_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/ui_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/ui_probe.gd" id="1"] [node name="UIProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/ui_struct_probe.gd b/game/probe/ui_struct_probe.gd similarity index 99% rename from game/smoke/ui_struct_probe.gd rename to game/probe/ui_struct_probe.gd index a406f570..4deede60 100644 --- a/game/smoke/ui_struct_probe.gd +++ b/game/probe/ui_struct_probe.gd @@ -8,10 +8,10 @@ # ④zero_size 可见件宽或高 ≤0=红 # ⑤start 专项 标题屏左列(start_box)与右列(start_right)矩形不相交(双列契约——叠放缺陷回归闸) # 豁免:WL 常量表按「屏名/规则名/件名」登记合法例外(登记须注理由于行尾注释)。 -# 运行:Godot_console.exe --headless --path . res://smoke/ui_struct_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/ui_struct_probe.tscn extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # 豁免清单(空 = 零豁免起步;红灯逐条核对后合法者入表并注理由) const WL := {} diff --git a/game/smoke/ui_struct_probe.gd.uid b/game/probe/ui_struct_probe.gd.uid similarity index 100% rename from game/smoke/ui_struct_probe.gd.uid rename to game/probe/ui_struct_probe.gd.uid diff --git a/game/smoke/ui_struct_probe.tscn b/game/probe/ui_struct_probe.tscn similarity index 63% rename from game/smoke/ui_struct_probe.tscn rename to game/probe/ui_struct_probe.tscn index 3467060e..9f12c43e 100644 --- a/game/smoke/ui_struct_probe.tscn +++ b/game/probe/ui_struct_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/ui_struct_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/ui_struct_probe.gd" id="1"] [node name="UIStructProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/ui_tune_probe.gd b/game/probe/ui_tune_probe.gd similarity index 99% rename from game/smoke/ui_tune_probe.gd rename to game/probe/ui_tune_probe.gd index 2e0a5c47..481cddc7 100644 --- a/game/smoke/ui_tune_probe.gd +++ b/game/probe/ui_tune_probe.gd @@ -8,10 +8,10 @@ # ⑨对局中可开(战斗屏角标真实信号路径)+棋盘输入不锁+状态角标不置灰(停机点回归闸) # +对局中两钮禁用+当局数据六项值对(与只读访问器逐项一致)+只读巡检(白名单外零连接)+零动效契约 # ⑩种子三分单点断言(显式输入优先/首局恒 42/非首局空框连抽 4 次不全等 42) -# 运行:Godot_console.exe --headless --path . res://smoke/ui_tune_probe.tscn +# 运行:Godot_console.exe --headless --path . res://probe/ui_tune_probe.tscn extends Node -const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点 +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:隔离样板单点 const SIM_GUARD_FRAMES := 1800 # ⑤ N=10 headless 模拟防挂死帧预算上限(探针内停滞检测第一道——坏态不等满预算才报,超时即断言失败) diff --git a/game/smoke/ui_tune_probe.gd.uid b/game/probe/ui_tune_probe.gd.uid similarity index 100% rename from game/smoke/ui_tune_probe.gd.uid rename to game/probe/ui_tune_probe.gd.uid diff --git a/game/smoke/ui_tune_probe.tscn b/game/probe/ui_tune_probe.tscn similarity index 62% rename from game/smoke/ui_tune_probe.tscn rename to game/probe/ui_tune_probe.tscn index 9d96ad75..12d5a68f 100644 --- a/game/smoke/ui_tune_probe.tscn +++ b/game/probe/ui_tune_probe.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://smoke/ui_tune_probe.gd" id="1"] +[ext_resource type="Script" path="res://probe/ui_tune_probe.gd" id="1"] [node name="UITuneProbe" type="Node"] script = ExtResource("1") diff --git a/game/smoke/adventure_monte_carlo.gd b/game/sim/adventure_monte_carlo.gd similarity index 98% rename from game/smoke/adventure_monte_carlo.gd rename to game/sim/adventure_monte_carlo.gd index e90ffb8f..95cbbae3 100644 --- a/game/smoke/adventure_monte_carlo.gd +++ b/game/sim/adventure_monte_carlo.gd @@ -7,7 +7,7 @@ # ② 全量操作日志 = playtest_logger 共享会话 JSONL(与真人试玩同合规口径,每策略一批一文件, # run 行携带种子/策略切分局次,落 user://logs/)。 # 本工具只采集与落盘数据,不做统计判读(通关率/败因分布归分析会话)。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/adventure_monte_carlo.gd -- [N] [seed0] [normal|perfect|both] [route min|max] +# 运行:Godot_console.exe --headless --path . --script res://sim/adventure_monte_carlo.gd -- [N] [seed0] [normal|perfect|both] [route min|max] extends SceneTree @@ -96,7 +96,7 @@ func _initialize() -> void: if not equipped.has(p): equipped.append(p) attune_profile = {"equipped": equipped, "refresh_y": 1} - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") var t0 := Time.get_ticks_msec() print("=== 冒险全流程扫描:三关连探 · N=%d/策略 · 种子 %d 起逐局独立 · 策略=%s · atk+0%%/hp+0%% 零加成 · route=%s ===" % [n, seed0, policy_arg, route]) for policy in policies: diff --git a/game/smoke/adventure_monte_carlo.gd.uid b/game/sim/adventure_monte_carlo.gd.uid similarity index 100% rename from game/smoke/adventure_monte_carlo.gd.uid rename to game/sim/adventure_monte_carlo.gd.uid diff --git a/game/smoke/auto_player.gd b/game/sim/auto_player.gd similarity index 100% rename from game/smoke/auto_player.gd rename to game/sim/auto_player.gd diff --git a/game/smoke/auto_player.gd.uid b/game/sim/auto_player.gd.uid similarity index 100% rename from game/smoke/auto_player.gd.uid rename to game/sim/auto_player.gd.uid diff --git a/game/smoke/battle_driver.gd b/game/sim/battle_driver.gd similarity index 97% rename from game/smoke/battle_driver.gd rename to game/sim/battle_driver.gd index edf07df1..9e0a908a 100644 --- a/game/smoke/battle_driver.gd +++ b/game/sim/battle_driver.gd @@ -6,7 +6,7 @@ # 不经本面(含回归断言,禁并入);fx_shot_probe 为带墙钟抓帧的异步变体亦不在列。 extends RefCounted -const AutoPlayer := preload("res://smoke/auto_player.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") const GUARD_ACTIONS := 400 # 单轮动作步防挂死上限(被收口探针原值逐一同值) diff --git a/game/smoke/battle_driver.gd.uid b/game/sim/battle_driver.gd.uid similarity index 100% rename from game/smoke/battle_driver.gd.uid rename to game/sim/battle_driver.gd.uid diff --git a/game/smoke/board_stat_probe.gd b/game/sim/board_stat_probe.gd similarity index 98% rename from game/smoke/board_stat_probe.gd rename to game/sim/board_stat_probe.gd index 3dae7f2e..70ef61d8 100644 --- a/game/smoke/board_stat_probe.gd +++ b/game/sim/board_stat_probe.gd @@ -4,7 +4,7 @@ # 对 board_gen.tres 每行 (L, D) 生成 N 张板,统计幸存雷数 |M| 与安全格数 |S| # 的均值 / 最小 / 最大("先布后删"后的落地值——名义值 density×(W×H−9) 不含删除段)。 # 只读诊断,零游戏代码改动;装载面与冒险扫描工具同源(config_db.load_tables)。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/board_stat_probe.gd -- [N] [seed0] +# 运行:Godot_console.exe --headless --path . --script res://sim/board_stat_probe.gd -- [N] [seed0] extends SceneTree diff --git a/game/smoke/board_stat_probe.gd.uid b/game/sim/board_stat_probe.gd.uid similarity index 100% rename from game/smoke/board_stat_probe.gd.uid rename to game/sim/board_stat_probe.gd.uid diff --git a/game/smoke/calibrate_boards.gd b/game/sim/calibrate_boards.gd similarity index 99% rename from game/smoke/calibrate_boards.gd rename to game/sim/calibrate_boards.gd index 54836ef1..afdbe38a 100644 --- a/game/smoke/calibrate_boards.gd +++ b/game/sim/calibrate_boards.gd @@ -3,7 +3,7 @@ # 逐 board_gen 行真实生成 N 板,统计 实际雷数/安全格数/R₀ 初始展开占比—— # 生成期「先布后删+可推理率重试」使实际雷数远低于表口径 round(d×(W×H−9)), # 日志侧只有清图板可反推 safe_total(未清图深度无样本),本工具给全网格精确分母。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/calibrate_boards.gd -- [N] +# 运行:Godot_console.exe --headless --path . --script res://sim/calibrate_boards.gd -- [N] # 输出:stdout CSV 行 level,depth,w,h,n,mines_mean,mines_med,safe_mean,safe_med,r0_mean,inf_mean extends SceneTree diff --git a/game/smoke/calibrate_boards.gd.uid b/game/sim/calibrate_boards.gd.uid similarity index 100% rename from game/smoke/calibrate_boards.gd.uid rename to game/sim/calibrate_boards.gd.uid diff --git a/game/smoke/damage_probe.gd b/game/sim/damage_probe.gd similarity index 98% rename from game/smoke/damage_probe.gd rename to game/sim/damage_probe.gd index 1d7c99c2..55241b8e 100644 --- a/game/smoke/damage_probe.gd +++ b/game/sim/damage_probe.gd @@ -8,11 +8,11 @@ # 观测面:全部读战斗结算步骤行 / 表值 / 呈现快照(零核心穿透);池归属/档位/下限全读表(数据配置 Rule 1——零数值字面量)。 # 模式(-- 后参数):boss [seed](默认 42:Run A 零挖掘 fresh 池 + Run B 自动挖掘授予行为) # level [seed](全关遭遇回放 + 多种子 spawn 扫描——报告 Bug#1 证据面) -# 运行:Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- boss 42 -# Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- level 42 +# 运行:Godot_console.exe --headless --path . --script res://sim/damage_probe.gd -- boss 42 +# Godot_console.exe --headless --path . --script res://sim/damage_probe.gd -- level 42 extends SceneTree -const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) +const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) var _checks := 0 var _fails := 0 diff --git a/game/smoke/damage_probe.gd.uid b/game/sim/damage_probe.gd.uid similarity index 100% rename from game/smoke/damage_probe.gd.uid rename to game/sim/damage_probe.gd.uid diff --git a/game/smoke/defense_scan.gd b/game/sim/defense_scan.gd similarity index 97% rename from game/smoke/defense_scan.gd rename to game/sim/defense_scan.gd index 26b369e3..0b6c8065 100644 --- a/game/smoke/defense_scan.gd +++ b/game/sim/defense_scan.gd @@ -2,7 +2,7 @@ # playtest-2026-09-12 Q1 复核——"安全格减伤是否过量导致满血打完 Boss" # 口径:逐结算轮统计 ac_up 授予层数(AC10→11→12)与该轮敌方实伤, # 分割"高 AC 轮"(叠满 = AC12,期望减免 28.75%)与基线轮(AC10,期望减免 21.25%)的承伤读数。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/defense_scan.gd -- [N] [seed0] +# 运行:Godot_console.exe --headless --path . --script res://sim/defense_scan.gd -- [N] [seed0] extends SceneTree @@ -19,7 +19,7 @@ func _initialize() -> void: print("表加载失败") quit(1) return - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") print("=== 盾牌加护叠层扫描(AC 口径):第一关 · N=%d · 种子 %d+%d ===" % [n, seed0, n]) print("%-6s %-6s %-8s %-12s %-14s %-14s" % ["seed", "轮数", "战斗轮", "满层轮(AC12)", "满层轮承伤", "非满层轮承伤"]) var full_rounds := 0 diff --git a/game/smoke/defense_scan.gd.uid b/game/sim/defense_scan.gd.uid similarity index 100% rename from game/smoke/defense_scan.gd.uid rename to game/sim/defense_scan.gd.uid diff --git a/game/smoke/dump_battle.gd b/game/sim/dump_battle.gd similarity index 92% rename from game/smoke/dump_battle.gd rename to game/sim/dump_battle.gd index 051e8c73..2d44544e 100644 --- a/game/smoke/dump_battle.gd +++ b/game/sim/dump_battle.gd @@ -1,10 +1,10 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2) # 一次性检查脚本:打印一场完整战斗的结算步骤流 + 棋盘摘要(肉眼验收 P2 归因质量)。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/dump_battle.gd +# 运行:Godot_console.exe --headless --path . --script res://sim/dump_battle.gd extends SceneTree -const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) +const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) func _initialize() -> void: var db = load("res://core/config_db.gd").new() diff --git a/game/smoke/dump_battle.gd.uid b/game/sim/dump_battle.gd.uid similarity index 100% rename from game/smoke/dump_battle.gd.uid rename to game/sim/dump_battle.gd.uid diff --git a/game/smoke/experiment_density.gd b/game/sim/experiment_density.gd similarity index 100% rename from game/smoke/experiment_density.gd rename to game/sim/experiment_density.gd diff --git a/game/smoke/experiment_density.gd.uid b/game/sim/experiment_density.gd.uid similarity index 100% rename from game/smoke/experiment_density.gd.uid rename to game/sim/experiment_density.gd.uid diff --git a/game/smoke/level_autoplay.gd b/game/sim/level_autoplay.gd similarity index 99% rename from game/smoke/level_autoplay.gd rename to game/sim/level_autoplay.gd index 662e756c..7c6a13dc 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -21,7 +21,7 @@ # 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。 extends RefCounted -const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现 +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现 # ── 普通档 v3 失误注入参数(踩雷期望=完美+2/板、每板少挖几个正确格)── # 标雷→误挖转化率:主踩雷源=卡死赌挖(不确定性概率命中),误挖只作偶发真手滑—— diff --git a/game/smoke/level_autoplay.gd.uid b/game/sim/level_autoplay.gd.uid similarity index 100% rename from game/smoke/level_autoplay.gd.uid rename to game/sim/level_autoplay.gd.uid diff --git a/game/smoke/monte_carlo.gd b/game/sim/monte_carlo.gd similarity index 96% rename from game/smoke/monte_carlo.gd rename to game/sim/monte_carlo.gd index cb88c195..f9600231 100644 --- a/game/smoke/monte_carlo.gd +++ b/game/sim/monte_carlo.gd @@ -1,10 +1,10 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # 战斗期望蒙特卡洛:当前参数下"胜轮数 / 每轮承伤 / 清图次数 / 开格数"的分布实测。 # 政策 = 理性玩家近似:挖最近可挖格(AutoPlayer 单一实现)。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/monte_carlo.gd +# 运行:Godot_console.exe --headless --path . --script res://sim/monte_carlo.gd extends SceneTree -const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) +const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) const N := 30 const SEED0 := 42 diff --git a/game/smoke/monte_carlo.gd.uid b/game/sim/monte_carlo.gd.uid similarity index 100% rename from game/smoke/monte_carlo.gd.uid rename to game/sim/monte_carlo.gd.uid diff --git a/game/smoke/reward_expectation.gd b/game/sim/reward_expectation.gd similarity index 99% rename from game/smoke/reward_expectation.gd rename to game/sim/reward_expectation.gd index 4a4f1840..58808422 100644 --- a/game/smoke/reward_expectation.gd +++ b/game/sim/reward_expectation.gd @@ -2,7 +2,7 @@ # 奖励期望测量:逐深度实测 |非封锁格|(safe ∪ mines ∖ R₀)分布、格面资源类型频次 # (应涌现自正向行九型权重 attack 14 / defense 11 / blast 16 / heal 4 / weaken 8 / pierce 8 / magic 8 / action 6 / gp 2,合计 77——reward.tres 现行)、 # 封锁格零泄漏(R₀ 格不在摇号清单)——走真实 地图战斗资源.roll_board 管线。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/reward_expectation.gd +# 运行:Godot_console.exe --headless --path . --script res://sim/reward_expectation.gd extends SceneTree const N := 200 diff --git a/game/smoke/reward_expectation.gd.uid b/game/sim/reward_expectation.gd.uid similarity index 100% rename from game/smoke/reward_expectation.gd.uid rename to game/sim/reward_expectation.gd.uid diff --git a/game/smoke/rhythm_batch.gd b/game/sim/rhythm_batch.gd similarity index 94% rename from game/smoke/rhythm_batch.gd rename to game/sim/rhythm_batch.gd index ebf9851f..ab4a7f4a 100644 --- a/game/smoke/rhythm_batch.gd +++ b/game/sim/rhythm_batch.gd @@ -2,7 +2,7 @@ # 技能池节奏读数批量采集(06 待办「技能池节奏体验验证」机器半边正式读数): # 60 distinct seeds × 双策略 × 两关,atk/hp 零加成基线——单网格组合避免种子重复 # (4200 系 0/484 假象教训:节奏/EV 读数须 ≥50 distinct seeds,见台账 89)。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/rhythm_batch.gd -- [seeds] [seed0] +# 运行:Godot_console.exe --headless --path . --script res://sim/rhythm_batch.gd -- [seeds] [seed0] extends SceneTree func _initialize() -> void: @@ -22,7 +22,7 @@ func _initialize() -> void: logger.ap_per_round = int(db.action_economy.ap_per_round) var log_path: String = logger.start_session(seed0) print("节奏批日志: %s" % log_path) - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") var t0 := Time.get_ticks_msec() var total := 0 for L in [1, 2]: diff --git a/game/smoke/rhythm_batch.gd.uid b/game/sim/rhythm_batch.gd.uid similarity index 100% rename from game/smoke/rhythm_batch.gd.uid rename to game/sim/rhythm_batch.gd.uid diff --git a/game/smoke/skill_rhythm_probe.gd b/game/sim/skill_rhythm_probe.gd similarity index 93% rename from game/smoke/skill_rhythm_probe.gd rename to game/sim/skill_rhythm_probe.gd index edfd02cb..456fe6c8 100644 --- a/game/smoke/skill_rhythm_probe.gd +++ b/game/sim/skill_rhythm_probe.gd @@ -4,10 +4,10 @@ # ① 表侧:resolve_enemy_rowset(1, 1, "battle") 的池快照内容; # ② 摇号侧:隔离 SkillLottery.draw 大样本份额; # ③ 战斗侧:真实 (1,1) 战斗逐执行行按 skill_id 计数。 -# 运行:Godot_console.exe --headless --path . --script res://smoke/skill_rhythm_probe.gd +# 运行:Godot_console.exe --headless --path . --script res://sim/skill_rhythm_probe.gd extends SceneTree -const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) +const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) func _initialize() -> void: var db = load("res://core/config_db.gd").new() diff --git a/game/smoke/skill_rhythm_probe.gd.uid b/game/sim/skill_rhythm_probe.gd.uid similarity index 100% rename from game/smoke/skill_rhythm_probe.gd.uid rename to game/sim/skill_rhythm_probe.gd.uid diff --git a/game/smoke/verifier_batch.gd b/game/sim/verifier_batch.gd similarity index 99% rename from game/smoke/verifier_batch.gd rename to game/sim/verifier_batch.gd index 065af3f5..0fb1cf35 100644 --- a/game/smoke/verifier_batch.gd +++ b/game/sim/verifier_batch.gd @@ -7,7 +7,7 @@ # campaign 段 营地循环战役 × 双画像 × k 局——SaveManager 隔离档 + MetaProgression 真实账本: # 逐局 hero_override() 注入 → 冒险 → settle_adventure 入账 → 花费策略买锻造, # 回答「累计几次通关冒险后下一关可过」(元进度换算率标定的模拟读数源) -# 运行:Godot_console.exe --headless --path . --script res://smoke/verifier_batch.gd -- [k=v ...] +# 运行:Godot_console.exe --headless --path . --script res://sim/verifier_batch.gd -- [k=v ...] # na=40(基线冒险局数) k=40(战役局数) seed0=8000 # only=adventure,campaign(缺省全段) spend=hp_first|atk_first|armor_first(锻造花费优先序) # inject=max_hp:3,atk:1(冒险段 flat 注入档——三键 {max_hp, atk, armor_class},缺省全零白板; @@ -42,7 +42,7 @@ func _initialize() -> void: logger.ap_per_round = int(db.action_economy.ap_per_round) var log_path: String = logger.start_session(opt.seed0) print("模拟日志: %s" % log_path) - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") var t0 := Time.get_ticks_msec() var counts := {"adventure": 0, "campaign": 0} if "adventure" in opt.only: diff --git a/game/smoke/verifier_batch.gd.uid b/game/sim/verifier_batch.gd.uid similarity index 100% rename from game/smoke/verifier_batch.gd.uid rename to game/sim/verifier_batch.gd.uid diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd deleted file mode 100644 index ee05c892..00000000 --- a/game/smoke/smoke_all.gd +++ /dev/null @@ -1,7165 +0,0 @@ -# VERTICAL SLICE - NOT FOR PRODUCTION -# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2) -# headless 冒烟入口(承数据配置冒烟框架:extends SceneTree + autoload 显式实例化)。 -# 运行:Godot_console.exe --headless --path --script res://smoke/smoke_all.gd -extends SceneTree - -const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现 -const ConfigDBScript := preload("res://core/config_db.gd") # T6 收口:表宿主 preload 常量(原 _initialize 运行期 load) - -var _failures := 0 -var _checks := 0 - - -func check(cond: bool, label: String, quiet: bool = false) -> void: - _checks += 1 - if cond: - if not quiet: - print(" [PASS] " + label) - elif quiet: - _failures += 1 - print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持 - else: - _failures += 1 - print(" [FAIL] " + label) - - -# 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) - - -func _initialize() -> void: - print("=== Dungeon Sweeper 垂直切片冒烟 ===") - var db = ConfigDBScript.new() - var load_ok: bool = db.load_tables("res://data/tables", true) - 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 参考实现逐位交叉验证一致) - check(rng.derive_at(0) == 8672514112661456564, "统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)") - check(rng.derive_at(1) == 8834291164406037215, "统一流向量 m=42 scope=1") - check(rng.derive_at(2) == 7714373262647006282, "统一流向量 m=42 scope=2") - check(rng.derive_at(3) == 1280102845971471347, "统一流向量 m=42 scope=3") - check(rng.derive_at(4) == 82585501446482399, "统一流向量 m=42 scope=4") - check(rng.derive_at(5) == 8078924421474470922, "统一流向量 m=42 scope=5") - check(rng.derive_at(6) == 6638052740430622424, "统一流向量 m=42 scope=6") - check(rng.derive_at(7) == 8264196488417721294, "统一流向量 m=42 scope=7") - check(rng.derive_at(0) == rng.derive_at(0), "derive_at 纯函数恒等") - # scope 取号语义:首个 open_stream = 全局 scope 0;计数器单调;用途标签不参与派生(只进事件日志) - var s0 = rng.open_stream("board_gen") - var s1 = rng.open_stream("encounter") - check(rng.counter() == 2, "open_stream 全局计数器单调 +1 ×2") - check(s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1), "首个取号 = scope 0(顺序确定)") - rng.new_adventure(4242) - var s2 = rng.open_stream("gp_drop") - check(rng.counter() == 1 and s2.seed == rng.derive_at(0), "new_adventure 重置全局计数器") - check(FileAccess.file_exists("user://logs/rng_log_4242.jsonl"), "事件日志会话落盘(2026-09-27 统一流裁决)") - - if load_ok: - _run_core_smoke(db, rng) - - _split_tail() # V2/V3——场景拆分+会话物化断言(挂树帧推进型异步尾段;摘要与退出码随尾段落) - - -# ---- 场景拆分/会话物化异步尾段(autoload 挂载需过 _initialize 后首帧〔run_all.gd 头注时序〕, -# 挂 main.tscn 的断言在帧后执行;摘要打印与 quit 在本尾段)---- -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 - _smoke_type_gate() - _smoke_hygiene_gates() - 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("=== 冒烟完成:%d 项检查,%d 项失败 ===" % [_checks, _failures]) - quit(1 if _failures > 0 else 0) - - -# ---- 类型闸断言(V7——project.godot warning 配置在位〔源码断言〕+审计器脚本在位; -# 审计器归零判定走 static_scan S9 闸机,此处兜配置面)---- -func _smoke_type_gate() -> void: - var pj: String = FileAccess.get_file_as_string("res://project.godot") - check(pj.contains("gdscript/warnings/untyped_declaration=1"), - "V7 类型闸 warning 配置在位(untyped_declaration=1 显形)〔迭代27 T5〕") - var scan_src: String = FileAccess.get_file_as_string("res://tests/static_scan.sh") - check(scan_src.contains("audit_type_hints.py"), - "V7 类型审计器闸机在位(static_scan S9 → tools/audit_type_hints.py)〔迭代27 T5〕") - - -# ---- 卫生件 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() - check(callables == 0, - "V8 字符串 Callable 产品码零命中(实测 %d——方法引用换装闸)〔迭代27 T6〕") - check(not FileAccess.file_exists("res://core/feats_engine.gd"), - "V8 FeatsEngine 整文件退役(class_name 注销=文件删除)〔迭代27 T6〕") - var bo: String = FileAccess.get_file_as_string("res://core/battle_orchestrator.gd") - check(not bo.contains("func _attune_row"), - "V8 死代码抽查·_attune_row 零残留〔迭代27 T6〕") - var sm: String = FileAccess.get_file_as_string("res://core/save_manager.gd") - check(not sm.contains("func attunement_counters"), - "V8 死代码抽查·attunement_counters 零残留〔迭代27 T6〕") - var mv: String = FileAccess.get_file_as_string("res://presentation/main.gd") - check(not mv.contains("gp_shadow_ledger") and not mv.contains("_gp_ledger"), - "V8 GP 影子账残留零命中(main 源码面——文件已删+委托缝已拆)〔迭代27 T3〕") - # 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(") - check(outside_calls == 0, - "V1d 层序落位单口(_apply_layer_contract 之外 move_child 调用点=%d 应=0)〔装配生命批 V1〕" % outside_calls) - -# ---- 三屏场景化断言(V2——独立实例化+关键节点在位+组合根树序/z 序保序)---- -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 - - -# 渲染面可见按钮递归计数(V2d mn1/mn2 变异锚——句柄缝盲区闭合) -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_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 成员并采) -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——两连局复位/无脏状态残留)---- -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) - - -# ---- Core 集成冒烟(表加载成功才执行)---- -var _db -var _rng - - -func _run_core_smoke(db, rng) -> void: - _db = db - _rng = rng - _smoke_ac_swap_guard() - _smoke_enemy_attack_type() - _smoke_enemy_bands() - _smoke_armor_roll() - _smoke_pool_wiring() - _smoke_board_generator() - _smoke_layout_cell_formula() - _smoke_type_glow() - _smoke_variation_framework() - _smoke_variant_placement(1) - _smoke_variant_placement(2) - _smoke_global_variant() - _smoke_full_battle(1, 0, "L1 · 地精") - _smoke_full_battle(2, 0, "L2 · 熊地精") - _smoke_level() - _smoke_seal_ap_exhausted() - _smoke_reward_identity_unit() - _smoke_power_interval() - _smoke_active_refresh() - _smoke_difficulty_table() - _smoke_damage_types() - _smoke_playtest_logger() - _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_encounter_roll() - _smoke_attack_chain_fresh_pool() - _smoke_clear_reward_chain() - _smoke_refresh_provenance_guard() - _smoke_polarity_routing() - _smoke_seal_irreversible_quadrants() - _smoke_negative_dictionary() - _smoke_resource_surface() - _smoke_settlement_segments() - _smoke_skill_pool() - _smoke_level_graph() - _smoke_gp_wallet() - _smoke_gp_escrow() - _smoke_shop_room() - _smoke_shop_graph() - _smoke_shop_pool() - _smoke_nine_types_gp_stream() - _smoke_adventure_table() - _smoke_adventure_api() - _smoke_adventure() - _smoke_feats() - _smoke_save() - _smoke_save_slots() - _smoke_meta() - _smoke_tree() - _smoke_tree_rules() - _smoke_counter_buckets() - _smoke_node_map_templates() - - -# ---- 关卡节点图生成·模板池冒烟(v0.4 §P0.6 断言面)---- -func _smoke_node_map_templates() -> void: - # ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志) - var svc = load("res://core/rng_service.gd").new() - check((svc.PURPOSE_TAGS as Array).has("node_map"), "node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)") - # ② 池参数:三关各 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", []) - check(tpls.size() == 5, "L%d 模板池 = 5 张(拍板点④——首版池规模)" % li) - check(not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"), - "L%d 模板 0 = 现行固定图(回滚档成员)" % li) - # ③ 强制取 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() - check(runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # start_level_external 签名收口(bonus 参数由 RunLedger 单类下传) - Callable(self, "_smoke_rest_stub"), {}, true, 0), "强制 template_pick=0 起关受理") - check(rng0.counter() == c_before, "强制取模板零 RNG 消耗(零抽签——首次脚本语义)") - var tpl0: Dictionary = {} - for r in _db.level.rows: - if int(r.level_index) == 2: - tpl0 = r.get("templates", [])[0] - check(runner0.level_row.get("nodes", []) == tpl0.get("nodes", []), - "强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)") - # ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图) - 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) - check(names[0] == names[1] and String(names[0]) != "", - "抽签确定性:同种子逐位同图(实测 %s)" % 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]}]) - check(lt.validate().is_empty(), "N=1 池表通过图校验(回滚档形态合法)") - # ⑥ 首次脚本分支: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}) - check(String(adrF.runner().active_template_name).ends_with("(现行)"), - "首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)") - 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}) - check(rngT.counter() == cF + 1, "抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)") - check(String(adrT.runner().active_template_name) != "", - "抽签模式 active_template_name 有值(MC 分层出数取数面在位)") - # ⑦ 收束置位:新档 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() - check(sv.is_first_adventure_done() == false, "新档首次冒险布尔位 = 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"}) - check(sv.is_first_adventure_done() == true, "冒险收束点置位(三结局同写——写入只在收束挂点)") - mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"}) - check(sv.is_first_adventure_done() == true, "置位幂等(重复收束不翻转)") - - -# 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位) -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) - - -# ---- 冒险结构冒烟(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 {} - - -# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检) -func _smoke_adventure_table() -> void: - print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") - check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)") - if _db.adventure == null: - return - check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)") - check(int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100, - "clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct)) - for tier in _db.adventure.TIERS: - check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]), - "档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])]) - check(int(_db.adventure.deck_total()) == 102, "令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕") - check(int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 - and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 - and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 - and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2, - "数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2") - check(int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1, - "恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕") - check(int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99, - "cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕") - check(Attunement.slots(_db, null) == 0, - "同调位读数源=树同调位节点点亮数(迭代 21 T4 槽位收编——null 档求值 0;谓词表已退役)〔天赋树批 V14〕") - # 同调目录 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 - check(trows.size() == 12 and tid_set.size() == 12, - "新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕") - 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 - check(names_ok, "命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕") - 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 - check(alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2, - "逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕") - var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191)) - check(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"), - "装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕") - var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192)) - check(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"), - "空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕") - # 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 - check(v9_ok, "新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕") - # 覆盖检查(效果三方向覆盖:信息〔巢穴预告/线香/天眼/阔视野带〕/行动资源〔晨操令/ - # 双管装填/试炼镐/储备罐〕/操作能力〔远程镐/信标网络〕+抉择刷新〔刷新练习/刷新执照〕——dimension_tags 静态投影) - var tag_set := {} - for r10 in trows: - for tg in (r10.get("dimension_tags", []) as Array): - tag_set[String(tg)] = true - check(tag_set.has("info") and tag_set.has("action") and tag_set.has("plan"), - "效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕") - # 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 - check(v18_ok and locked_n == 12, - "装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕") - - -# ② 冒险结构 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)) - check(adr.start_adventure(42, {"first_script": true}), "冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)") - check(not adr.start_adventure(43), "进行中重复 start_adventure 拒绝(重开须待结算完毕)") - check(sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented", - "停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)") - check(adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \ - and adr.runner().current_battle() == null, - "入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)") - check(adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])), - "pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)") - check(sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null, - "pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)") - check(not adr.abandon(), "战斗中 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) - check(adr.runner().current_battle() == null and adr.runner().awaiting_choice(), - "首战胜后选路态(非战斗期——current_battle 置空)") - check(adr.abandon(), "非战斗期 abandon 受理(选路态——同失败型结算)") - var sum: Dictionary = adr.summary - check(String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)), - "放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1))) - check(not adr.abandon(), "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)") - - -# 观测桩(信号序采集——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 跨关延续/加算区同账/恢复数学/放弃) -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〕) - 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 - check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss) - # ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复) - var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) - var sum: Dictionary = st["summary"] - check(String(st["result"]) == "completed" and bool(sum.get("cleared", false)), - "满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)") - check(int(sum.get("levels_cleared", -1)) == 3, "通关几关 = 3(levels_cleared)") - 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)) - check(int(sum.get("keystone_total", -1)) == ks_exp, - "钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1))) - var drops: Array = sum.get("keystone_drops", []) as Array - check(drops.size() == 3, "逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)") - check(int(sum.get("master_seed", -1)) == 42, "摘要携带主种子(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"]) - check(picks.size() == 2 and resolves.size() == 2 + node_picks, - "通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)") - check(node_picks >= 1, "奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks) - var node_resolves := 0 - for r0 in resolves: - if String((r0 as Dictionary).get("context", "")) == "reward_node": - node_resolves += 1 - check(node_resolves == node_picks, "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [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)) - # 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15 - # 账本生效上限口径:置满/回血封顶按生效上限(flat 后乘调试浮窗 pct)——窗口同步 ×5(本段 pct=400) - var heal_ok_i := false - for extra in 46: - var m := int(round(float(int(_db.hero.max_hp) + hp_acc + extra) * 5.0)) - if heal == mini(int(floor(float(m) * float(heal_pct) / 100.0)), m - hp_before): - heal_ok_i = true - break - if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 45) * 5.0)): - 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)) - check(cand_ok, "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)") - check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役;购血容差窗口)") - check(ks_gain_ok, "钥石逐杀入账(首杀档位 + 累计逐位)") - 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 - check(seq_ok, - "各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % exp_base) - # ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致) - var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) - check(st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq - and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"], - "同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)") - # ⑤ 败北线(零配 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 - check(not lose.is_empty(), "零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1))) - if not lose.is_empty(): - var sl: Dictionary = lose["st"]["summary"] - check(not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \ - and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1, - "L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层") - var dl: Dictionary = sl.get("defeat", {}) - check(int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \ - and String(dl.get("node_type", "")) in ["battle", "boss"], - "L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)") - 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 - check(not half.is_empty(), "半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1))) - if not half.is_empty(): - var sh: Dictionary = half["st"]["summary"] - check(not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \ - and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \ - and int(sh["stopped_at"]) == 2, - "L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层") - var dh: Dictionary = sh.get("defeat", {}) - check(int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \ - and String(dh.get("node_type", "")) in ["battle", "boss"], - "L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)") - # ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化) - var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7)) - adr6.set_hero_override(900, 400) - check(adr6.start_adventure(7, {"first_script": true}), "重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)") - 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) - 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"}) - check(adr6.summary == sum6_before, "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)") - # ⑦ 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 标记——不计战斗口径) - check(gains == int(st["battles"]), "每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])]) - check(clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear", - "钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)") - var shop_seq: Array = st["shop_gp_seq"] - check(shop_seq.size() == 2 and int(shop_seq[0]) > 0, - "L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1)) - # ⑧ 加算区同账(手工场景: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 - check(not same.is_empty(), "同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1))) - 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)) - check(gp0 >= hp_price + l2_price_bonus, - "进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus]) - check(r8.buy_product("hp_up"), "购买 hp_up 受理(出账 + 加算区)") - var acc_shop := int(a8.run_ledger.bonus.get("max_hp", -1)) # L1 三选一后 + 商店 3(首件随种子) - check(acc_shop >= 3, "统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop) - 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 载荷直调——非末关) - check(not a8._curio_pending.is_empty(), "L2 通关三选一停点在案(直调兑付前——零自动推进)") - check(not a8.continue_to_next_level(), "未兑付推进拒绝(选择停点零自动推进契约)") - var l2_cands: Array = a8._curio_pending["candidates"] - var l2_pick: Dictionary = l2_cands[0] - 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) - check(int(a8.run_ledger.bonus.get("max_hp", -1)) == expect_acc, - "三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.run_ledger.bonus.get("max_hp", -1)), acc_shop, picked2]) - var max_eff8: int = a8.effective_max_hp() # 账本生效上限含调试浮窗 pct(a8 已 set 400) - check(int(a8.ledger["hero_hp"]) <= max_eff8, - "通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8]) - check(a8.continue_to_next_level(), "兑付后推进受理(下一关开始)") - # ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致) - var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99)) - check(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, - "恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法") - var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99)) - check(int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5, - "同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)") - # ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算) - var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15)) - a10.set_hero_override(900, 400) - check(a10.start_adventure(15, {"first_script": true}), "放弃场景:冒险启动(seed 15——固定模板 0 同上)") - check(a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])), - "放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)") - check(not a10.abandon(), "战斗中 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) - check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理") - # ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现; - # 无种类次数上限,「达上限剔除」语义由牌库耗尽承载) - var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11)) - check(a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}), - "剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)") - 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 - check(not draw_kinds.has("attack") and not draw_kinds.has("ap"), - "池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw)) - check((draw as Array).size() >= 2, "池耗后候选恒 ≥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 - check(not treasure_seen, "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)") - # 满血回 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"}) - check(int(cap["payload"].get("heal_gained", -1)) == 0, - "满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)") - # ⑬ 奖励节点共享池(同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现) - var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13)) - check(a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}), - "节点共享池场景:冒险启动(seed 13)") - 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 - check(not has_attack13, - "奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13)) - a13.present_node_curio(ncands13) - a13.present_node_curio([{"kind": "gp", "tier": "common"}]) - var gp13 := int(a13.ledger.get("gp", 0)) - check(a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common")), - "奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common"))) - check(a13._node_curio_pending.is_empty(), "节点停点态兑付后清零(恰一次语义同构)") - var s10: Dictionary = a10.summary - check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)), - "放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)") - - -# ---- 专长冒烟(feats 四级 · 案D 池接线 · V3 订阅面逐件三列)---- - -func _smoke_feats() -> void: - print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---") - # ① 现行表牌库构成:30 件全量入池——102 数值令牌+30 relic 令牌(四级 rarity 直映射) - check(_db.feats != null and (_db.feats.rows as Array).size() == 30, "feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)") - check(_db.feats.validate().is_empty(), "feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)") - var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) - check(a_r0.start_adventure(71, {"first_script": true}), "专长带场景:冒险启动(seed 71)") - 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 - check(feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0._curio_deck.size() == 132, - "专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕") - # ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查) - check(_db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty(), - "旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕") - check(_db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty(), - "迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕") - # ③ 契约面静态钉扎(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 - check(kinds_ok, "层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕") - 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 - check(rewrite_rows == 0, "棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕") - 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 - check(bad_words == 0, "对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕") - check(float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 - and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 - and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3, - "数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕") - # ④ 临时表注入(新 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"}}]}, - ] - check(rt.validate().is_empty(), "新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)") - var live_rows: FeatTable = _db.feats - _db.feats = rt - var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) - check(a_r.start_adventure(72, {"first_script": true}), "专长带场景:冒险启动(seed 72——注入 5 行临时表)") - var deck_relic := 0 - for t in a_r._curio_deck: - if String((t as Dictionary).get("kind", "")) == "relic": - deck_relic += 1 - check(deck_relic == 5 and a_r._curio_deck.size() == 107, - "专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=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 - check(feat_seen.size() >= 2, "通关渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)") - check(not node_relic_seen, "节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)") - # 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"}]} - check(a_t.apply_node_curio("feat_ticket", "excellent"), "A5 专长凭证兑付受理(节点渠道——拿到即开)") - 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 - check(tkt_all_relic, "A5 拿到即开——停点候选已替换为专长三选一(全 relic)") - check(a_t.pool_stock("attack") == int(stock_before["attack"]) and a_t.pool_stock("gp") == int(stock_before["gp"]), - "A5 零摘牌——专长凭证兑付不动令牌库(不进持有集)") - check(not (a_t.feats_held() as Array).has("feat_ticket"), "A5 无专长凭证残留持有集") - 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", ""))) - check((a_t.feats_held() as Array).size() == feats_n + 1, "A5 专长面兑付受理(专长凭证开出候选可正常入持有集)") - # 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") - check(a_t._feat_ticket_consider(rg_t), "A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)") - check(a_t._feat_ticket_pity == 0, "A6 触发后清零") - 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) - check(not a_t._feat_ticket_consider(rg_t), "A6 限购满不再触发(保底冻结)") - 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 - check(offered, "A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)") - a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}} - check(a_r.pick_curio("relic", "rare", "x3") and (a_r.feats_held() as Array).has("x3") and a_r.pool_stock("relic") == deck_relic - 1, - "relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1]) - # 断点往返(持有集+余库 feat_id 随行) - var sm_r = load("res://core/save_manager.gd").new("user://_smoke_feats_bp.json") - 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() - check((bp_r.get("relics", []) as Array).has("x3"), "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)") - check(int((bp_r.get("adventure_counters", {}) as Dictionary).size()) >= 0, "断点附随件:adventure 计数器账本键在案(T6/T7 扩列)") - 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 - check(bp_deck_x3 == 0, "断点余库:已兑付 relic 令牌逐枚还原(x3 零残留——续玩不重复获得)") - sm_r.clear_breakpoint() - _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: - check(refund, "双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" % (i + 1)) - else: - check(not refund, "双管镐反例:第 %d 次不返(未满 2 次)" % (i + 1)) - fx.rebuild(["geologist"], [], []) - var geo_hits: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": true}) - apply_fx.call(geo_hits) - check(orch._hits_of(geo_hits, "counter_add").size() == 1, "地质学家:首开新类型集 1 点〔专长批 V3〕") - 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) - check(orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.counters_ctx, "geo_types") == 0, - "地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕") - var geo_hits3: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": false}) - check(orch._hits_of(geo_hits3, "counter_add").is_empty(), "地质学家反例:同类型重复首开不叠点〔专长批 V3〕") - fx.rebuild(["hard_hat"], [], []) - apply_fx.call(fx.dispatch(orch.counters_ctx, "dig_reveal", {})) - apply_fx.call(fx.dispatch(orch.counters_ctx, "dig_reveal", {})) - check(EffectEngine.counter(orch.counters_ctx, "helm_layers") == 2, "安全帽:开安全格攒层 ×2〔专长批 V3〕") - 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", ""))) - check(hh_reduce == 2, "安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕") - fx.rebuild(["skip_mining"], [], []) - var sk_zero: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": true}) - apply_fx.call(sk_zero) - check(orch._hits_of(sk_zero, "counter_add").size() == 1, "跳格采掘:空白 0 格攒 1 点〔专长批 V3〕") - 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) - check(orch._hits_of(sk_full, "reveal_tiles").size() == 1, - "跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕") - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty(), - "跳格采掘反例:普通开格不计点〔专长批 V3〕") - 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) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1, "爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕") - fx.rebuild(["frontier_miner"], [], []) - check(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(), - "边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕") - # 封印系 - 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", {}) - check(orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue", - "封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕") - 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 - check(sm_remove == 1, "封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕") - fx.rebuild(["seal_stake"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1 - and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round", - "封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕") - fx.rebuild(["warning_beacon"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "preview_area").size() == 1, "示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕") - # 踩雷/受伤系 - 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", ""))) - check(pm_bonus == 2, "疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕") - 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)) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty(), - "保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕") - 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))) - check(fw_shield == 0, "保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕") - fx.rebuild(["blood_insight"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1, "见血开窍:踩雷揭示 1 格〔专长批 V3〕") - 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", ""))) - check(ti_val == 2, "陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕") - 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", {})) - check(EffectEngine.counter(orch.counters_ctx, "pain_layers") == ct_before + 2, "连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕") - fx.rebuild(["trap_burst"], [], []) - var tb_hits: Array = fx.dispatch(orch.counters_ctx, "mine_hit", {"damage": 8}) - check(orch._hits_of(tb_hits, "blast_scaled").size() == 1 - and float((orch._hits_of(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0, - "陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕") - fx.rebuild(["blood_honey"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "heal").size() == 1, "血蜜:踩雷回复 N 生命〔专长批 V3〕") - fx.rebuild(["stone_skin"], [], []) - var ss_hits: Array = fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1}) - check(orch._hits_of(ss_hits, "ac_bonus").size() == 1 - and int((orch._hits_of(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3, - "石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕") - fx.rebuild(["last_stand"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1 - and orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty(), - "背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕") - # 跨族桥 - fx.rebuild(["breaker_strike"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 - and orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty(), - "破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕") - # 节奏系 - 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 载荷取值)——冒烟手动落账 - check(orch._hits_of(me_hits, "counter_add").size() == 1, "弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕") - 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", ""))) - check(me_atk == 4, "弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕") - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty(), - "弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕") - fx.rebuild(["last_drop"], [], []) - var ld_hits: Array = fx.dispatch(orch.counters_ctx, "round_start", {"round": 2, "ap_exhausted_last": true}) - check(orch._hits_of(ld_hits, "grant_ap").size() == 1, "一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕") - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty(), - "一滴不剩反例:上轮有余不发〔专长批 V3〕") - 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) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1, "收盘:本轮开格≥8→+1 GP〔专长批 V3〕") - # 战斗系 - fx.rebuild(["execution_scholar"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1, - "斩首学者:击杀揭示 1 格〔专长批 V3〕") - fx.rebuild(["executioner"], [], []) - var ex_hits: Array = fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 30, "is_boss": false}) - check(orch._hits_of(ex_hits, "target_attack_bonus").size() == 1 - and orch._hits_of(fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty(), - "处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕") - fx.rebuild(["field_dressing"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1, - "战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕") - # 契约尖点 - fx.rebuild(["blood_tax"], [], []) - check(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", - "血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕") - # 迁入 2 件 - fx.rebuild(["venom_edge"], [], []) - check(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(), - "淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕") - fx.rebuild(["seal_burst"], [], []) - check(orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "open_ticket").size() == 1, - "封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕") - # 零持有 - var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74)) - orch0._fx.rebuild([], [], []) - check(orch0._fx_emit("attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}).is_empty() - and orch0._fx_emit("dig_reveal", {"cell": Vector2i(1, 1), "round": 1, "is_first_dig_of_battle": true, "terrain_kind": []}).is_empty() - and orch0._fx_emit("mine_hit", {"damage": 5}).is_empty() - and orch0._fx_emit("battle_won", {"round": 1}).is_empty(), - "零持有订阅=零命中(relic 关着零扰动——伤害管线既有行为逐位不变)") - # ⑦c 特性持有呈现段(C 区「特性」节;呈现面在 06 在册) - var panel = load("res://presentation/views/hero_info_panel_view.gd").new() - panel.set_feat_holdings([]) - check(not panel._feat_section.visible, "特性段空态隐藏(视图空态纪律同特性段)") - panel.set_feat_holdings(["疼痛记忆 ×1", "双管镐 ×1"]) - check(panel._feat_section.visible and panel._feat_list.get_child_count() == 2, - "特性段持有呈现(逐件行常驻可读——「局内奖励选了什么看不到」缺口承载)") - check(String(load("res://presentation/battle_narrator.gd").feat_card_text({"feat_id": "pain_memory", "display_name": "疼痛记忆", "desc": "触陷阱攒 1 层"})).contains("触陷阱攒 1 层"), - "特性卡文案表值化:desc 行字段直读(零硬编码映射)") - - -# 手工场景驱动: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 的同构子集) -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} - - - -# ---- 战斗屏棋盘布局·格边长公式回归(真人试玩 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 - check(grid_h <= int(avail.y) - 24, "L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" % [L, b.width, b.height, grid_h, int(avail.y), int(cell)]) - check(cell >= 26.0 and cell <= 46.0, "L%d 格边在 26–46 档内(%dpx)" % [L, int(cell)]) - board_view.free() - - -# ---- 未开格类型微光·数据侧断言(V1 作用集恒等/V2 九型归组封闭/V7 零玩法写入)---- -# 夹具纪律:身份回调形状与 reward_economy.query_identity 同构({reward_id, cell_type, tier}); -# 位点覆盖表 _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 - - -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() - check(bool(snap.get("enabled", false)), "V1 微光默认开(Q4 丙案——呈现态布尔缺省 true,生产写入面零显式开)") - check((snap.get("errors", []) as Array).is_empty(), "V1 夹具全集合法九型 ⇒ 清单外登记为空(禁静默空光通道零误报)") - var expect: Dictionary = _glow_expected_set(b, ms) - check(_glow_key_set_equal(snap.get("cells", {}), expect), - "V1 作用集恒等:微光格集合 == _diggable ∩ 身份可查(期望 %d,实测 %d)" % [expect.size(), (snap.get("cells", {}) as Dictionary).size()]) - check(expect.size() >= 5, "V1 夹具前提:英雄未开邻格 ≥5(实测 %d——novice %d 步)" % [expect.size(), steps]) - # 已开格/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) + " " - check(opened_leak == "", "V1 已开格/r0 出生展开区零微光(S1)%s" % ("" if opened_leak == "" else "违例:" + 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() - check(mapped >= 5, "V2 夹具前提:未开邻格派定 ≥5 型(实测 %d)" % mapped) - 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]])] - check(map_bad == "", "V2 九型→五组逐格映射(attack/defense/action/gp→增益/heal→治疗/pierce→穿刺/blast→群伤/magic→魔法)%s" - % ("" if map_bad == "" else "违例:" + 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", []) - check(errs.has("keystone"), "V2 清单外 cell_type 显式登记(errors=%s)" % str(errs)) - check(not (snap.get("cells", {}) as Dictionary).has(bogus_cell), - "V2 清单外格零微光(无静默空光——%s 不在作用集)" % str(bogus_cell)) - check((snap.get("cells", {}) as Dictionary).size() == mapped - 1, - "V2 清单外格剔除后作用集 = 派定数 −1(%d→%d)" % [mapped, (snap.get("cells", {}) as Dictionary).size()]) - # ---- 相 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() - check(not (snap.get("cells", {}) as Dictionary).has(blocked_cell), - "V1 封锁格零微光(query null ⇒ 天然惰性——%s)" % str(blocked_cell)) - check((snap.get("errors", []) as Array).size() == 1, - "V1 封锁格零登记(errors 仍仅清单外一条——%s)" % str(snap.get("errors", []))) - check((snap.get("cells", {}) as Dictionary).size() == mapped - 1, - "V1 封锁格剔除后作用集 = 派定数 −1(实测 %d)" % (snap.get("cells", {}) as Dictionary).size()) - # ---- 相 4:封锁解除 ⇒ 作用集复位(进出圈复亮走回)---- - _glow_blocked.erase(blocked_cell) - view.refresh() - snap = view.type_glow_probe() - check(_glow_key_set_equal(snap.get("cells", {}), expect), - "V1 封锁解除后作用集复位恒等(期望 %d,实测 %d)" % [expect.size(), (snap.get("cells", {}) as Dictionary).size()]) - # ---- 相 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() - check(not bool(snap.get("enabled", true)), "V7 off 态开关读数=false") - check((snap.get("cells", {}) as Dictionary).is_empty(), "V7 off 态作用集为空(基线保护面)") - view.set_type_glow_enabled(true) - var snap_on: Dictionary = view.type_glow_probe() - check(bool(snap_on.get("enabled", false)), "V7 on 态开关读数=true(复现)") - check(_glow_map_equal(snap_on.get("cells", {}), snap_before_off.get("cells", {})), - "V7 off→on 作用集逐位复现(关前 %d 格/开后 %d 格,组号逐位一致)" % [ - (snap_before_off.get("cells", {}) as Dictionary).size(), (snap_on.get("cells", {}) as Dictionary).size()]) - check(_glow_key_set_equal(snap_on.get("cells", {}), expect), - "V7 off→on 作用集与独立重算期望集恒等(%d 格)" % (snap_on.get("cells", {}) as Dictionary).size()) - var revealed_after: Array = ms.revealed_cells() - revealed_after.sort() - check(revealed_before == revealed_after and hero_before == ms.hero_pos() and seals_before == ms.seal_count(), - "V7 微光开关零玩法写入(已开集/英雄位/封印数三面对照逐位一致)") - # ---- 相 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) - check(_glow_key_set_equal(snap2.get("cells", {}), expect2), - "V3 换图 rebuild 后作用集按新板重算(期望 %d,实测 %d——零旧板残留)" % [expect2.size(), (snap2.get("cells", {}) as Dictionary).size()]) - check((snap2.get("errors", []) as Array).is_empty(), "V3 换图清清单外登记(rebuild 清零)") - 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 - check(diverge == "", "契约 1 同板同序列 on/off 两态结算逐位一致(%d 步对照——A 开态/B 关态)%s" - % [seq_steps, diverge]) - check(seq_steps >= 5, "契约 1 对照前提:同序列 ≥5 步(实测 %d)" % seq_steps) - viewA.free() - viewB.free() - - -# 排雷 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()] - - -# ---- 地图战斗资源·身份面单元冒烟(格面资源: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)}) - check(reward_economy.roll_board(eligible), "roll_board 受理") - check(not reward_economy.roll_board(eligible), "重复 roll_board 被拒(恰一次——double-roll 守卫)") - # 身份查询投影 = {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 - check(types_ok, "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" % str(types_seen.keys())) - # 清单外坐标 = null(E19——封锁格不入清单的同款出口) - check(reward_economy.query_identity(Vector2i(99, 99)) == null, "清单外坐标查询 = null(封锁格无身份同口径)") - # 同格重复受理幂等:同一快照逐字段相同 - var snap1: Dictionary = reward_economy.grant(eligible[0].cell) - var snap2: Dictionary = reward_economy.grant(eligible[0].cell) - check(JSON.stringify(snap1) == JSON.stringify(snap2), "同格重复 grant 幂等(同一快照——受理恰一次)") - check(reward_economy.grant(Vector2i(99, 99)) == null, "清单外 grant 拒绝") - # 清图批尾身份:clear_default 唯一活行 clear_blast(blast 型正向);重调幂等 - var cs1: Dictionary = reward_economy.grant_clear() - var cs2: Dictionary = reward_economy.grant_clear() - check(String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast", "清图批尾身份 = clear_blast(clear_default 唯一活行)") - 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))] - check(int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1]), - "清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)]) - check(JSON.stringify(cs1) == JSON.stringify(cs2), "grant_clear 同批次重调幂等") - # 退役后受理拒绝 - reward_economy.retire_mapping() - check(reward_economy.grant(eligible[0].cell) == null, "映射退役后 grant 拒绝(生命周期闭合)") - - -# ---- 地图战斗资源·伤害量值区间冒烟(roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)---- - - -# ---- 排雷玩法 封印动作耗尽末点 AP 回归(playtest 缺陷②——v1 冒烟盲区:自动玩家不用封印)---- - -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)) - check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)") - 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}]) - check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1, "%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap]) - # 末点 AP 花在封印——轮次必须终结并结算进入第 2 轮 - # 封印目标 = 挖掘策略的下一目标格(构造保证:未开、与小人邻接——先把小人移到其邻接已开格) - var plan2 = AutoPlayer.choose_dig(minesweeper) - 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}]) - check(not out.is_empty() and bool(out[0].accepted), "末点 AP 封印被受理") - check(rounds.size() >= 2, "封印动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" % str(rounds)) - - -# ---- 关卡节点地图·线性推进冒烟---- - -func _smoke_level() -> void: - print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---") - var autoplay = load("res://smoke/level_autoplay.gd") - var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) - # ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖) - var win = autoplay.run_level(_db, 42, 1, 200, 100) - check(win.result == "victory", "关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index]) - check(win.battles == 5, "战斗节点数 = 5(战×4 + Boss)") - check(win.boss_depth == 4, "Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)") - check(win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3, "进 Boss HP 携带在域内(%d)" % win.hp_at_boss) - # ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2 - # seed 42(探针目的 = 验回复公式,种子仅载体) - var rest = autoplay.run_level(_db, 42, 1, 100, 100) - if rest.result != "start_failed" and rest.hp_before_rest >= 0: - var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线) - var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest) - check(rest.rest_healed == expect, "恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max]) - else: - check(false, "恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result) - # ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关) - var lose = autoplay.run_level(_db, 42, 1, -80, -80) - check(lose.result == "defeat", "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result) - check(lose.defeat_node_index >= 0 and lose.defeat_node_index < 7, "败北节点记录在域内(节点 %d)" % lose.defeat_node_index) - # ④ 回放确定性:同种子两遍步骤流逐行相同 - var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) - var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) - check(JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows), "关卡回放断言:同种子两遍结算步骤流逐行相同") - check(rep1.rows.size() > 0, "关卡步骤流非空(%d 行)" % rep1.rows.size()) - # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随) - check(int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \ - and int(_db.hero.armor_class) == int(hero_base.ac), - "模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)]) - - -# ---- 敌人表深度带键结构(迭代 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", ""))]) - check(_db.enemy.rows.size() == 20 and got_keys == expect_keys, - "敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕") - # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) - var hob := {} - for row in _db.enemy.rows: - if String(row.get("enemy_id", "")) == "hobgoblin": - hob = row - check(int(hob.get("level_index", -1)) == 1 and String(hob.get("row_kind", "")) == "battle" \ - and int(hob.get("depth_band", -1)) == 3, - "hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕") - check(FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行"), - "hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕") - # ③ 三 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] - check(boss_ok, "三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [ - _boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail]) - - -# ---- 敌方攻击类型轴(表列/单位快照/单据载荷/矩阵命中证词)---- -# 克制矩阵命中证词 = 同种子三甲型配对实测(不是查表存在性证词):同种子下 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] - check(at_ok, "敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" % [ - _db.enemy.rows.size(), at_detail if at_detail != "" else "无"]) - # ②③④ 矩阵命中证词扫描:(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 - check(snap_ok, "敌方单位快照 attack_type 非空(spawn 注入面——迭代 24 N3)") - check(matrix_found and t_light >= 2, - "克制矩阵命中证词:同种子三甲型实测 heavy %d == light %d − 1 · ward %d == light %d + 1(迭代 24 N3 全局平移 −1/0/+1)" % [ - t_heavy, t_light, t_ward, t_light]) - check(payload_at == "normal", "敌方伤害技单据载荷 attack_type 非空(= %s——第 9 参传值面,迭代 24 N3)" % payload_at) - - -# ---- 护甲等级掷点·AC 单轨护栏(无 percent 减伤双轨)---- -# ① defense_up 状态行/池映射/奖励行三处零残留,ac_up 三处就位(unit=ac·只续时不叠层); -# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行 -# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(池内现无 percent 行——本断言防未来负向行回归时静默造墙); -# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物文档); -# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。 - -func _smoke_ac_swap_guard() -> void: - print("--- 护甲等级掷点批·换装护栏 ---") - # ① 三处无双轨 - check(_db.status_effect_row("defense_up").is_empty(), "状态表 defense_up 行已退役(无双轨)") - var ac_row: Dictionary = _db.status_effect_row("ac_up") - check(not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \ - and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \ - and String(ac_row.get("stack_policy", "")) == "refresh", - "ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)") - 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 - check(String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护", - "奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)") - check(not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up"), - "effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)") - # ② 免伤墙归零护栏(泛化——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] - check(wall_ok, "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [ - wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)]) - # ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚) - check(int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30, - "主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class)) - 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] - check(ac_band, "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [ - ac_detail if ac_detail != "" else "无"]) - # ③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)) - check(int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)), - "Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), 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) - check(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}, - "熊地精首遇带(1,battle,%d)降档锚定(hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1;首遇带数据驱动定位)〔fv21〕" % l12_band) - 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] - check(curve_ok, "第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" % [ - curve_detail if curve_detail != "" else "无"]) - # ④ 掷点参数域(加载校验已过——此处钉形状语义) - var bounds: Array = _db.settlement.roll_tier_bounds - var discounts: Array = _db.settlement.roll_tier_discounts - check(int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \ - and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \ - and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll", - "掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)") - check(bounds == [3, 7] and discounts == [75, 50, 25], "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)") - - -# ---- 护甲等级掷点·行为面(折扣档分布 / 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 - check(tier_ok and dist == cfg.expect, "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])]) - 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) - check(ev_sum == 1575, "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum) - # ② 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 - check(crit_seed > 0, "nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.settlement.crit_roll)]) - check(int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false)), - "nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)") - check(int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk), - "nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)]) - # ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置 - # (削弱为续时语义,无叠层通道) - var weaken_probe := _weaken_refresh_probe() - check(int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2, - "削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)") - check(int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude), - "削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)") - # ④ 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 - check(tiers_valid and tiers.size() >= 3, "AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" % tiers.size()) - check(tier_distinct.size() >= 2, "折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" % str(tier_distinct.keys())) - # ⑤ 棋盘来源必中 + 吃掷点行留档(全战斗流扫描) - 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 # 棋盘来源(爆裂/直伤/清障炸药)必中全额——不掷不留档 - check(all_positive, "全战斗流零 v=0 伤害行(伤害下限全局生效)") - check(attack_ok, "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)") - check(board_ok, "棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)") - # ⑥ 全来源伤害下限(任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤/反噬/地形变异/爆裂 - # 全部收在 damage_floor 闸内) - var df: int = int(_db.settlement.damage_floor) - check(DamageMath.counter_component(2, -1, df) == 1, "棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)") - check(DamageMath.counter_component(1, -3, df) == 1, "克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)") - check(DamageMath.counter_component(0, 5, df) == 0, "空桶不吃修正恒 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 [] - - -# ---- 主角乘区接线 + 硬化折半方向 ---- -# 同种子停手轮恒同掷点(效果只改乘区、不动掷点流)→ 落地伤害精确关系: -# 增伤 = 基线 ×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)) - check(int(base_row.get("normal", -1)) == base_v and base_v > 0, "单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" % base_v) - check(int(up_row.get("value", -1)) == int(floor(base_v * 1.5)), - "增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" % [base_v, int(up_row.get("value", -1))]) - check(int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75)), - "乏力经乘区削减合计(%d → %d = ×0.75)" % [base_v, int(sapped_row.get("value", -1))]) - check(int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v, - "硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" % [base_v, int(fortify_row.get("value", -1))]) - - -# 构造停手轮首行主角攻击行: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_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) - check(not row.is_empty(), "BoardGen 行解析 (%d,0)" % L) - if L == 2: - # R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4 - var ceil_row: Dictionary = _db.resolve_board_gen(2, 3) - check(int(ceil_row.get("node_depth", -1)) == 4, "R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)") - var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db) - check(b1 != null, "L%d 生成产物非空" % L) - if b1 == null: - continue - check(board_generator.is_clearable(b1), "L%d clearable(安全格 8 邻连通)" % L) - check(board_generator.legality(b1, row, _db), "L%d legality 全过" % L) - 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 断言承担。 - check(inf >= float(row.inferability_target) or mode == "accept_best", - "L%d inferability %.3f %s target %.2f(accept_mode=%s)" % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode]) - check(b1.variants.is_empty(), "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" % L) - check(b1.metrics.has("degraded_product") and b1.metrics.has("target_mines"), "L%d 指标集输出降级标记与目标雷数(三分出口表)" % L) - check(b1.degraded_product == (mode == "accept_best"), "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" % [L, mode, str(b1.degraded_product)]) - check(b1.illegal_product == (mode == "accept_worst"), "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)]) - check(not b1.degraded_product or not b1.illegal_product, "L%d 降级/非法产物标记互斥(两出口不可同真)" % L) - check(int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines), "L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)]) - check(b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts), "L%d attempts_used ∈ [1,%d]" % [L, int(row.max_gen_attempts)]) - check(not b1.illegal_product, "L%d 非法产物标记 = false" % L) - # 生成确定性:同种子逐格相同 - 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) - check(_board_dump(b2) == _board_dump(b3), "L%d 同种子两次生成逐格相同" % L) - var b4 = board_generator.generate(L, 0, _mk_stream("board_gen", 778), _db) - check(_board_dump(b2) != _board_dump(b4), "L%d 异种子产物不同(退化护栏)" % L) - # ---- 初始展开收窄断言 ---- - # ① 结构断言: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)) - check(cap_val >= BoardData.SPAWN_AREA_CELLS, "L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" % [L, cap_val]) - check(int(b1.metrics.r0_count) <= cap_val, "L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" % [L, int(b1.metrics.r0_count), cap_val]) - check(int(b1.metrics.get("r0_cap_rejected", -1)) >= 0, "L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" % [L, int(b1.metrics.get("r0_cap_rejected", -1))]) - check(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, "L%d ring_removed=%d ≤ 剃雷域候选危险格数((3+2r)²−9——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))]) - 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) - check(b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product), - "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"]) - check(int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "", "L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))]) - - -func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator: - # stream_name = 调用点自述标签(不参与派生),保留参数零改调用方 - var rg := RandomNumberGenerator.new() - rg.seed = _rng.derive_at(scope) - return rg - - -# ---- 地形变异 框架面(四行两轴×两关 + 门控 + 跨字段互锁)---- - -func _smoke_variation_framework() -> void: - print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---") - check(_db.variation.rows.size() == 6, "变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)") - check(_db.variation.validate().is_empty(), "变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)") - var pool1_ids: Array = [] - for r in _db.variant_pool(1, "dungeon"): - pool1_ids.append(String(r.variant_id)) - check(pool1_ids == ["thorn", "vine"], "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)") - var pool2_ids: Array = [] - for r in _db.variant_pool(2, "underdark"): - pool2_ids.append(String(r.variant_id)) - check(pool2_ids == ["magma", "ash"], "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)") - check(_db.variant_pool(1, "underdark").is_empty(), "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)") - var pool2d_ids: Array = [] - for r in _db.variant_pool(2, "dungeon"): - pool2d_ids.append(String(r.variant_id)) - check(pool2d_ids == ["thorn", "vine"], "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)") - check(_db.variation.row_by_id("nonexistent").is_empty(), "清单外 variant_id 返回空(读取通道在位)") - # 跨字段互锁负样本(加载期致命——枚举语义一致性闸) - 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": []}]) - check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)") - 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": []}]) - check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒") - - -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 取号断言——子类拦截计数,派生与取数语义零改动) -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) - - -# 板行覆盖桩(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_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": []}]) - check(_errs_has_cross_field(vt.validate()), "互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)") - 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": []}]) - check(_errs_has_cross_field(vt.validate()), "互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)") - 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": []}]) - check(_errs_has_cross_field(vt.validate()), "互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 整图恒在)") - 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": []}]) - check(_errs_has_cross_field(vt.validate()), "互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)") - 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 - check(domain_hit, "互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)") - # ② 门控第三维(节点身份——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)) - check(pool_boss_ids == ["magma", "ash"], "门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)") - var pool_battle_ids: Array = [] - for r in _db.variant_pool(2, "underdark", "battle"): - pool_battle_ids.append(String(r.variant_id)) - check(pool_battle_ids == ["magma", "ash"], "门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)") - check(_db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2, - "门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)") - check(_db.variant_pool(1, "underdark", "boss").is_empty(), "L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)") - check(_db.global_variant_row(2, "underdark", "battle").is_empty(), "全局决议:battle 节点恒无全局型") - check(_db.global_variant_row(2, "underdark", "boss").is_empty(), "全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)") - check(_db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty(), - "全局决议:L1 boss 全局池空 → 空行(兜底回落承载)") - check(_db.global_variant_row(2, "underdark", "elite").is_empty(), "elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)") - # ③ 兜底回落:全局池空 → 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"}) - check(boss_ok, "L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)") - var boss1_ids: Array = [] - var boss1_variants: Dictionary = {} - var boss1_calls := 0 - if boss_ok: - var bb = boss_settlement.minesweeper.current_board() - check(bb.board_variant_ids().is_empty(), "屏蔽后回落:Boss 图无整图全局型(board_variants 空——全局池空合法态)") - var boss_vids := {} - for vc in bb.variants: - for vid in bb.variants[vc]: - boss_vids[String(vid)] = true - check(boss_vids.size() == 1 and (boss_vids.has("magma") or boss_vids.has("ash")), - "屏蔽后回落:Boss 图 region 变异恰一行且在候选池内(实测 %s)" % str(boss_vids.keys())) - check(not bb.variants.is_empty(), "屏蔽后回落:Boss 图恢复 region 变异格布置(原替换制跳过 region 的逻辑随全局池空失效)") - check(crng.variant_calls > 0, "屏蔽后回落:variant_place 恢复取号(原全局取用零取号态消失)") - var snap: Dictionary = boss_settlement.presentation_snapshot() - check(_arr_str_eq(snap.get("board_variants", []), bb.board_variant_ids()), "快照投影:board_variants 空态一致(横幅/角标唯一读取面——无全局型即零预告)") - 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() - check(_arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls, - "确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)") - # ④ 资源面偏斜投影(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) - check(base_sum == 77, "偏斜基线:正向池现行合计 77(挖掘风险批口径)") - eco.board_skew = 0 - var off_rows: Array = eco._biased_cell_rows(0) # 内容数据驱动扩展批 T4:偏运权重改 reward_roll 订阅命中入参(luck_bias 注入位退役) - check(off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows), "off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)") - eco.board_skew = 30 - var on_rows: Array = eco._biased_cell_rows(0) - check(eco._pool_weight(on_rows) == 77, "合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)") - var wmap := _rows_weight_map(on_rows) - check(wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2, - "白名单豁免:weaken/gp/action 三行偏斜后逐位不动") - check(wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7, "生存族下调:heal 4→3 / defense 11→7(v=30 构造例)") - check(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, - "进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)") - 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) - check(eco._pool_weight(combo_rows) == 77 + 4 * 2, "合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8") - check(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, - "合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)") - eco.board_skew = 50 - var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows(0)) - check(eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0, "域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)") - # 普通图摇号全链路确定性(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)}) - check(n1 == _board_resource_dump(normal2) and not n1.is_empty(), "确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)") - - -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 _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}): - check(false, "L%d 布置段战斗启动失败" % L) - return - var b = settlement.minesweeper.current_board() - check(b != null, "L%d 布置段板非空" % L) - 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 - check(not bad_id, "L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)]) - check(single_type, "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L) - 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 - check(domain_ok, "L%d 变异格全在初始展开区外且不吃危险格(布置域结构保障——closed_only)" % L) - 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) - check(vcells.size() <= expect_cap, "L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap]) - # 数量公式量随候选池缩放:L1 无关卡加成,极小池(<10 格)可 round 至 0—— - # 零布置为合法态(同池空口径);非零断言为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0) - check(expect_cap == 0 or vcells.size() > 0, "L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L) - 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 - check(pack_ok, "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max]) - # 布置确定性:全新 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() - check(b2 != null and _variant_dump(b2) == _variant_dump(b), "L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L) - - -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 _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) - - -# ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ---- - -func _smoke_full_battle(L: int, D: int, label: String) -> void: - print("--- 完整战斗回放:%s ---" % label) - var run1 = _run_battle(L, D) - var run2 = _run_battle(L, D) - check(run1.terminated, "%s 战斗在轮次上限内终局(%d 轮,%s)" % [label, run1.rounds, run1.result]) - if JSON.stringify(run1.stream) != JSON.stringify(run2.stream): - for i in range(maxi(run1.stream.size(), run2.stream.size())): - var a := JSON.stringify(run1.stream[i]) if i < run1.stream.size() else "" - var b := JSON.stringify(run2.stream[i]) if i < run2.stream.size() else "" - if a != b: - print(" 流差异 @%d:\n run1=%s\n run2=%s" % [i, a, b]) - break - check(JSON.stringify(run1.stream) == JSON.stringify(run2.stream), "%s 回放断言:结算步骤流逐行相同" % label) - check(JSON.stringify(run1.events) == JSON.stringify(run2.events), "%s 回放断言:事件流逐位相同" % label) - check(run1.stream.size() > 0, "%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()]) - var has_verdict := false - var hero_attacks := 0 - var hero_breakdown_ok := true - for row in run1.stream: - if row.get("type", "") == "verdict": - has_verdict = true - if row.get("source_class", "") == "hero_attack": - hero_attacks += 1 - # 单分量伤害构成行:hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因) - # 类型键主语 = 锻造工坊配置默认值(hero.tres 无两轴字段;本夹具零注入) - var atk_t: String = String(_db.forge_default_attack_type()) - if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")): - hero_breakdown_ok = false - check(has_verdict, "%s 步骤流含判定行" % label) - # 案C:授予段不产出伤害——进入战斗段时主角恒存活且场上有敌, - # 先手必兑(地图账在战斗段末整体结账)。 - check(hero_attacks >= 1, "%s 主角先手攻击行必存在(2026-09-11 拍板① + 案C 地图账殿后)×%d" % [label, hero_attacks]) - check(hero_breakdown_ok, "%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" % label) - - -func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: - # 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款) - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(42) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false} - settlement.step_row.connect(func(row): out.stream.append(row)) - settlement.battle_finished.connect(func(payload): out.result = payload.get("result", "")) - settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt)) - settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) - settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) - settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) - var minesweeper = settlement.minesweeper - var action_economy = settlement.action_economy - var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} - enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) - if not settlement.begin_battle(enc): - out.stream.append({"type": "verdict", "value": "begin_failed"}) - return out - var max_rounds := 40 - while out.result == "" and out.rounds < max_rounds: - out.rounds += 1 - _auto_play_round(settlement, out) - if out.result == "": - out.result = "timeout" - out.terminated = out.result == "victory" or out.result == "defeat" - if out.terminated: - # P0-3 回归断言:notify_combat_end 曾被并进行尾注释从不执行—— - # 战斗收束后相位必须回 Idle(RAP 清零),任何「相位卡 Waiting」回归在此被抓 - check(action_economy.phase == "Idle", - "战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" % String(action_economy.phase)) - 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"}]) - - - -# ---- 地图战斗资源·伤害量值区间冒烟(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 - check(anchors_ok and interval_rows.size() >= 1, - "区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" % [interval_rows.size(), str(interval_rows.keys())]) - # ② 运行侧: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 := {} - for rid in interval_rows.keys(): - seen[rid] = {} # rid → {roll 值集}(区间行随表注册) - 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 - 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] = {} - seen[c_rid][cv] = true - if interval_rows.has(c_rid) and (cv < interval_rows[c_rid][0] or cv > interval_rows[c_rid][1]): - in_domain = false - check(in_domain, "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)") - check(fixed_ok, "定值行快照 power 恒等表值——区间批零外溢") - # 定值行(区间 lo==hi 的行,如 heal_minor)roll 恒等表值;区间行(lo 0 and not (samples == 1 and seen[rid].has(lo)): - fixed_rows_ok = false - elif samples >= 2 and seen[rid].size() < 2: - variance_ok = false - check(variance_ok, "区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)") - check(fixed_rows_ok, "定值行(lo=hi)roll 恒等表值") - - -func _row_power(rid: String) -> int: - for row in _db.reward.rows: - if String(row.reward_id) == rid: - return int(row.power) - return -1 - - - - -# ---- 行动经济·轮内刷新冒烟(方案 A:Active 相位当场换图继续挖)---- - -func _smoke_active_refresh() -> void: - print("--- 行动经济·轮内刷新(方案 A)---") - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(20260912) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - # 计数器用容器(GDScript lambda 对局部 int 按值捕获——int 计数在 lambda 内加无效) - var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0, "waiting_refresh_rejected": true, "waiting_probe_hit": false} - settlement.board_changed.connect(func(): ct["board_swaps"] += 1) - settlement.minesweeper.cell_revealed.connect(func(evt): - if evt.kind == "safe" and evt.channel != "initial": - ct["safe_events"] += 1) - settlement.step_row.connect(func(row): - if row.source_class == "safe_cell": - ct["safe_rows"] += 1 # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行) - # 结算中(Waiting 相位——段内)发起刷新必须被拒(等待期换图通道回归探针: - # 衰减行恒在每结算出现,借其在段内同步探测一次) - if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]): - ct["waiting_probe_hit"] = true - ct["waiting_refresh_rejected"] = not settlement.player_request_refresh()) - check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)") - var minesweeper = settlement.minesweeper - # 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——AP 从表读不写死) - for i in range(3): - minesweeper.settle_batch([{"action": "stop"}]) - var rap_banked: int = 3 * int(_db.action_economy.ap_per_round) - check(settlement.round_index == 4 and settlement.action_economy.rap == rap_banked, "三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap]) - check(settlement.action_economy.phase == "Active", "第 4 轮 Active 相位") - # 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限) - var plan = AutoPlayer.choose_dig(minesweeper) - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - var ap_before: int = settlement.action_economy.ap - var board_before = minesweeper.current_board() - var swaps_before: int = ct["board_swaps"] - var ok1: bool = settlement.player_request_refresh() - check(ok1, "轮内第 1 次刷新受理") - check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before, "相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap) - check(settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost), "RAP -刷新价(%d)" % settlement.action_economy.rap) - check(int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before, "当场换新图(board_changed +1)") - var plan2 = AutoPlayer.choose_dig(minesweeper) - if plan2.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}]) - minesweeper.settle_batch([{"action": "dig", "cell": plan2.dig_cell}]) - var ok2: bool = settlement.player_request_refresh() - check(ok2, "轮内第 2 次刷新受理(RAP=%d)" % settlement.action_economy.rap) - var plan3 = AutoPlayer.choose_dig(minesweeper) - if plan3.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan3.hero_cell}]) - minesweeper.settle_batch([{"action": "dig", "cell": plan3.dig_cell}]) - var ok3: bool = settlement.player_request_refresh() - check(not ok3, "第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" % settlement.action_economy.rap) - # V5(刷新双账收口):受理计数单源=结算快照 economy.refresh_used,与 can_refresh 门一致 - check(int(settlement.presentation_snapshot().get("refresh_used", -1)) == 2, - "V5 快照刷新计数=2(受理单源——main 副本已删)〔迭代27 T3〕") - check(not settlement.can_refresh(), "V5 上限达门禁一致(can_refresh 与快照计数同源)〔迭代27 T3〕") - # 跨图 facts 完整兑现:拒绝后挖到 AP 耗尽自动结算(扩大跨图样本——三段挖掘分属三张图) - var dig_guard := 20 - while settlement.action_economy.phase == "Active" and settlement.round_index == 4 and dig_guard > 0: - dig_guard -= 1 - var p = AutoPlayer.choose_dig(minesweeper) - if p.is_empty(): - minesweeper.settle_batch([{"action": "stop"}]) - break - if p.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": p.hero_cell}]) - minesweeper.settle_batch([{"action": "dig", "cell": p.dig_cell}]) - check(settlement.round_index == 5 and settlement.battle_result == "", "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" % [settlement.round_index, settlement.battle_result]) - check(int(ct["safe_rows"]) == int(ct["safe_events"]), "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" % [int(ct["safe_rows"]), int(ct["safe_events"])]) - check(int(ct["safe_events"]) >= 3, "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" % int(ct["safe_events"])) - check(bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]), "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)") - # 回放确定性:同种子两遍步骤流逐行相同(含两次轮内刷新取号序) - var rep1 = _active_refresh_replay() - var rep2 = _active_refresh_replay() - check(JSON.stringify(rep1) == JSON.stringify(rep2), "轮内刷新回放断言:同种子两遍步骤流逐行相同") - check(rep1.size() > 0, "轮内刷新步骤流非空(%d 行)" % rep1.size()) - - -func _active_refresh_replay() -> Array: - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(20260912) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - var rows: Array = [] - settlement.step_row.connect(func(row): rows.append(row)) - if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): - return rows - var minesweeper = settlement.minesweeper - for i in range(3): - minesweeper.settle_batch([{"action": "stop"}]) - for attempt in range(3): - var plan = AutoPlayer.choose_dig(minesweeper) - if not plan.is_empty(): - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - settlement.player_request_refresh() - minesweeper.settle_batch([{"action": "stop"}]) - return rows - - -# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)---- - -func _smoke_difficulty_table() -> void: - print("--- 难度曲线与Boss·DifficultyTable 结构位 ---") - check(_db.difficulty != null and _db.difficulty.rows.is_empty(), "DifficultyTable MVP 空表加载(结构位)") - # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 - var enemy_system = load("res://core/unit_container.gd").new(_db) - check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理") - var unit = enemy_system.all_enemies()[0] - var d0: Dictionary = _db.resolve_enemy_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) - check(int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk), "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), 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) - check(enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)), "修饰后 spawn 受理") - var g2 = enemy_system_b.all_enemies()[0] - check(int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp), "hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)]) - check(int(g2.atk) == int(d0.atk) * 2, "atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)]) - # ③ 越域 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) - check(enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)), "越域修饰 spawn 受理") - check(int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10, "越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)]) - _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) - - -# ---- 攻击类型 × 护甲克制 + 标错惩罚冒烟(护甲快照/克制矩阵/充能/反噬三选一)---- - -func _smoke_damage_types() -> void: - print("--- 攻击类型 × 护甲克制 + 标错惩罚 ---") - # ① 护甲类型快照传播(spawn 行值 → 单位字典) - var enemy_system = load("res://core/unit_container.gd").new(_db) - check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light", "goblin 护甲 = light(轻甲——教学位)") - var enemy_system_b = load("res://core/unit_container.gd").new(_db) - check(enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light", "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)") - var enemy_system_c = load("res://core/unit_container.gd").new(_db) - check(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", "ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)") - # ② 克制矩阵表值在位(三档点数 克制 +1 / 中性 0 / 被克 −1,加法核非 percent) - var am: Dictionary = _db.settlement.armor_effectiveness - check(int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1, "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)") - check(int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0, "穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)") - check(int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1, "魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +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 # 标中的正向兑付行(该危险格资源的正向字典行——标中只走格面资源正向) - check(miss_rows >= 1, "标错封印产生 seal_miss 归因行(×%d)" % miss_rows) - check(negative_rows >= 1, "标错封印兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" % negative_rows) - check(hit_rows >= 1, "封印命中兑付该危险格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" % hit_rows) - # ④ 同种子回放(极性路由零随机——同种子逐行相同) - var run2 = _run_seal_backlash_round(20260912) - 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} - - -# 板真相直读找封印目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径) -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 _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)) - check(settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "四情况 A:开局受理") - var ms_a = settle_a.minesweeper - var t_a := _find_seal_target(ms_a, false) - check(not t_a.is_empty(), "四情况 A:找到安全格封印目标") - 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}]) - check(ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}])[0].accepted, "四情况 A:封印受理") - var pull_out: Array = ms_a.settle_batch([{"action": "seal_pull", "cell": t_a.seal_cell}]) - check(not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog", - "四情况 A:seal_pull 出目录拒绝(撤封印退役)") - var dup_place: Array = ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}]) - check(not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "seal_present", - "四情况 A:重复封印仍被拒(seal_present)") - var dig_a: Array = ms_a.settle_batch([{"action": "dig", "cell": t_a.seal_cell}]) - check(not dig_a.is_empty() and bool(dig_a[0].get("accepted", false)), "四情况 A:封印格直接挖开被受理(不再须先撤封印)") - check(not ms_a.has_seal(t_a.seal_cell), "四情况 A:封印随挖掘隐式退役(has_seal = false)") - 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 - check(miss_a == 0, "四情况 A:标空判定零兑付(×%d——封印已擦除不入轮末基线)" % miss_a) - check(grant_a == 0, "四情况 A:安全格正向兑付作废(×%d——零奖励)" % grant_a) - check(void_a == 1, "四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" % void_a) - # 场景 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)) - check(settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "四情况 B:开局受理") - var ms_b = settle_b.minesweeper - var t_b := _find_seal_target(ms_b, true) - check(not t_b.is_empty(), "四情况 B:找到危险格封印目标") - 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}]) - check(ms_b.settle_batch([{"action": "seal_place", "cell": t_b.seal_cell}])[0].accepted, "四情况 B:封印受理") - var dig_b: Array = ms_b.settle_batch([{"action": "dig", "cell": t_b.seal_cell}]) - check(not dig_b.is_empty() and bool(dig_b[0].get("accepted", false)), "四情况 B:封印危险格直接挖开被受理") - check(ms_b.is_revealed(t_b.seal_cell) and ms_b.current_board().is_mine(t_b.seal_cell), "四情况 B:危险格已揭示(挖雷本体照常)") - 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 - check(mine_hit_b >= 1, "四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" % mine_hit_b) - check(hit_b == 0, "四情况 B:标中不发(×%d——封印已擦除不判)" % hit_b) - # 场景 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)) - check(settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "四情况 C:开局受理") - var ms_c = settle_c.minesweeper - var t_safe := _find_seal_target(ms_c, false) - var t_mine := _find_seal_target(ms_c, true) - check(not t_safe.is_empty() and not t_mine.is_empty(), "四情况 C:安全格+危险格封印目标齐备") - 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 - check(miss_c >= 1 and miss_neg_c >= 1, "四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" % [miss_c, miss_neg_c]) - check(hit_c >= 1, "四情况 C:标中奖励照常(×%d)" % hit_c) - - -# ---- 试玩日志器冒烟(结算快照 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)) - check(path.contains("playtest_20260912"), "会话文件开启(%s)" % path) - 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"}]) - check(snaps.size() == 2, "每结算一快照(%d 次结算 → %d 快照)" % [2, snaps.size()]) - 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) - check(missing.is_empty(), "快照字段齐备(缺 %s)" % str(missing)) - check(int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"), "快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq)) - # spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→ - # unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位 - check(int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)), - "快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))]) - logger.log_action("dig", Vector2i(1, 1), true) - var diag: String = logger.dump_diagnosis({"test": true}) - check(diag.contains("diag_"), "诊断转储文件生成(%s)" % 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 - check(lines >= 3 and parse_ok, "JSONL 全部行可解析(%d 行 = session + 2 结算)" % lines) - logger.close_session() - - -# ---- 遭遇随机化冒烟(难度曲线与Boss Core Rule 8:区间骰/encounter 流/可回放/空表定值路径)---- - -func _smoke_encounter_roll() -> void: - print("--- 遭遇随机化(unit_count 区间骰)---") - var counts: Array = [] - for i in range(20): - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(30000 + i) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", true) - counts.append(settlement.presentation_snapshot().enemies.size()) - # 区间生效:20 个种子应出现 1 和 2(D0 [1,2]) - check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts)) - # 域内断言带标签(断言必须能抓住失效,禁恒真 check(true)) - var all_in_domain := true - for c in counts: - if c < 1 or c > 2: - all_in_domain = false - check(all_in_domain, "全部计数在域内 [1,2](分布 %s)" % str(counts)) - # 同种子可回放:两遍同数量(encounter 流确定性) - var r1 := _encounter_replay(30000) - var r2 := _encounter_replay(30000) - check(r1 == r2, "同种子遭遇骰可回放(%s == %s)" % [str(r1), str(r2)]) - # D1 [2,3]:seed 30005 路径(任意种子域内即可) - var rng3 = load("res://core/rng_service.gd").new() - rng3.set_master_seed(31000) - var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3) - check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}), "D1 战斗启动") - var n1: int = settlement_d1.presentation_snapshot().enemies.size() - check(n1 >= 2 and n1 <= 3, "D1 区间 [2,3] 域内(%d 只)" % n1) - # 熊地精首遇带固定(教学位不加骰)——首遇带数据驱动定位(契约 8,禁写死坐标) - var bugbear_lead_band := _first_encounter_band(1, "bugbear") - var bugbear_lead_row: Dictionary = {} - for row in _db.enemy.rows: - if int(row.level_index) == 1 and String(row.row_kind) == "battle" \ - and int(row.depth_band) == bugbear_lead_band and String(row.enemy_id) == "bugbear": - bugbear_lead_row = row - break - var rng4 = load("res://core/rng_service.gd").new() - rng4.set_master_seed(31000) - var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4) - settlement_d2.begin_battle({"level_index": 1, "node_depth": bugbear_lead_band, "hero_hp": int(_db.hero.max_hp)}) - check(settlement_d2.presentation_snapshot().enemies.size() == 1 \ - and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max"), - "熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" % bugbear_lead_band) - # 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0: - # 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式) - var depths: Array = [] - for node in _level_battle_nodes(1): - depths.append(int(node.get("node_depth", -1))) - var scan_bad := 0 - var scan_total := 0 - for i in range(12): - var rngs = load("res://core/rng_service.gd").new() - rngs.set_master_seed(32000 + i) - for depth_v in depths: - scan_total += 1 - var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs) - if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}): - scan_bad += 1 - continue - var n: int = settlement_scan.presentation_snapshot().enemies.size() - var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v), "battle")[0] # 行集首行(扫描面 = 战斗节点) - var lo2: int = int(erow2.get("unit_count", 1)) - var hi2: int = int(erow2.get("unit_count_max", lo2)) - if n < 1 or n < lo2 or n > hi2: - scan_bad += 1 - check(scan_bad == 0, "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" % scan_total) - - -# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维) -func _row_kind_of(level_index: int, depth: int) -> String: - for n in _level_active_nodes(level_index): - if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss": - return "boss" - return "battle" - - -# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) -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 [] - - -func _level_battle_nodes(level_index: int) -> Array: - var out: Array = [] - for row in _db.level.rows: - if int(row.level_index) == level_index: - for node in _level_active_nodes(level_index): - var t := String(node.get("type", "")) - if t == "battle" or t == "boss": - out.append(node) - return out - - -# ---- 战斗段 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("attach", 0)), "roll": int(row.roll), - "tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)})) - check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "fresh 池回归战斗启动(Boss 行)") - # 停手轮:零挖掘零封印 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计) - for i in range(6): - if settlement.battle_result != "": - break - settlement.minesweeper.settle_batch([{"action": "stop"}]) - check(ticks.size() >= 2, "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size()) - 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 - check(all_full, "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)") - -func _encounter_replay(seed_val: int) -> Array: - var out: Array = [] - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(seed_val) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) - out.append(settlement.presentation_snapshot().enemies.size()) - return out - - -# ==================== 清图奖励双份兑付链 / 轮内刷新 provenance 守卫 / 窗口锚行 ==================== -# 清图 = 全安全格开完。清图测试用"真雷封印(零误标→完美封印)+ AutoPlayer 逐轮清图"真实流驱动; -# DifficultyTable 注入 hp_mult=10 把 Boss 血量抬到 1500,保证清图批次发生在战斗结束之前(测试台账,跑完即还原)。 - -const CLEAR_SMOKE_DEPTH := 4 -const CLEAR_SMOKE_HERO_HP := 100000 -var _regular_clear_reward_rows := 0 # 常规(非刷新图)清图的 clear_reward 行数——刷新守卫对照基线 - - -func _smoke_clear_reward_chain() -> void: - print("--- 清图奖励·双份兑付链(完美封印 / 零兑付 / 第二份被拒 / 高光记实)---") - # ① 真实流完美封印:标中一面真雷封印 + 零误标 + 清全图 → grant_clear 身份(clear_blast)双份真实兑付 - var rows: Array = [] - var run := _run_full_clear(20260915, false, rows) - check(bool(run.cleared), "① 完美封印清图达成(%d 轮)" % int(run.rounds)) - 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() - check(dmg.size() == 2, "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" % dmg.size()) - if dmg.size() == 2: - check(String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份"), - "① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)") - check(hl.size() == 1 and int(hl[0].value) == 2, "④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" % hl.size()) - if hl.size() == 1: - check(String(hl[0].get("highlight", "")) == "perfect_sweep", "高光标记 = 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)) - 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() - check(snap != null and String(snap.cell_type) == "blast", - "清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)") - rows.clear() - var first_ok: bool = s._grant_clear_identity(snap, 1, "(清图奖励)") - check(not first_ok, "② 即时类首份零兑付(无存活敌)→ first_ok=false") - check(_has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast"), - "② 零兑付产出 reward_ineffective 行(batch_key=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)) - 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) - check(_has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast"), - "② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案") - check(_rows_of_type(rows_b, "highlight").is_empty(), "② 首份零兑付 → 高光行不发(不记名义值 2)") - # ③ 第二份被拒:唯一存活敌 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)) - 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) - check(_has_source_row(rows2, "damage", "clear_reward"), "③ 首份 aoe 真实兑付(击杀唯一存活敌)") - check(_has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast"), - "③ 第二份被拒产出 reward_ineffective 行(零兑付)") - var hl2 := _rows_of_type(rows2, "highlight") - check(hl2.size() == 1 and int(hl2[0].value) == 1, "④ 高光行 value = 实际有效份数 1(不记名义值 2)") - - -# 轮内刷新 provenance 守卫:刷新换来的图同轮清图不发清图奖励(_board_from_midround_refresh 守卫) -func _smoke_refresh_provenance_guard() -> void: - print("--- 轮内刷新 provenance 守卫(刷新图清图不发清图奖励)---") - var rows: Array = [] - var run := _run_full_clear(20260916, true, rows) - check(bool(run.refreshed), "轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)") - check(bool(run.cleared), "刷新图清图达成(%d 轮)" % int(run.rounds)) - 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 - check(cr == 0, "刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" % cr) - check(hl == 0, "刷新图清图 → 无高光行(守卫在位——实测 %d 行)" % hl) - check(_regular_clear_reward_rows > 0, "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" % _regular_clear_reward_rows) - - -# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ==================== -# 极性路由断言(战斗结算 验收):挖安全格/封印命中只出正向、挖危险格/标错封印只出负向、兑付行指回来源格。 -# 正向字典效果集 = {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 缓冲路由键)。 -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 - - -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 - check(polarity_seed > 0, "极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed) - check(bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"]), "构造触达:标中真雷 + 标空安全格均入队") - check(int(probe["mine_events"]) >= 1, "构造触达:挖到危险格 ≥1(%d)" % int(probe["mine_events"])) - check(int(probe["safe_events"]) >= 1, "构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"])) - # 挖到的安全格:只出正向——其全部来源行必存在且 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 - check(safe_all_pos, "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" % probe["safe_cells"].size()) - # 挖到的危险格:只出负向——来源行必存在且 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 - check(mine_all_neg, "挖危险格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" % probe["mine_cells"].size()) - # 封印命中:该危险格正向兑付行(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 != "" and not POSITIVE_EFFECTS.has(eff): - hit_ok = false - check(hit_ok, "封印命中 → 该危险格资源的正向兑付行(origin 指回危险格,效果 ∈ 正向字典)") - # 条款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) - check(prem_pct == 125, "封印溢价配置在位(seal_hit_premium=125 provisional——形态 a 兑付溢价)") - 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)) - check(prem_rows >= 1 and prem_ok, "标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" % [prem_rows, prem_pct]) - # 标空不乘溢价:负向直伤行(反噬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] - 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 - 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 - check(int(seal_srcs.size()) <= 2, "封印判定兑付仅封印判定批两封印在案(撤封印抵消过滤零泄漏——%d 格)" % seal_srcs.size()) - - -# 极性路由探针驱动:标 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_negative_dictionary() -> void: - print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---") - # 数据面:sapped 行在位(状态表七行 + effect_pool_map 映射在位) - var sapped: Dictionary = _db.status_effect_row("sapped") - check(not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \ - and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \ - and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack", - "sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)") - check(String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense", - "effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)") - # 行为面:逐型负向兑付形态(真实板 + 真实路由路径) - # 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 - check(by_type.size() == 5, "扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys())) - # 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] - check(_has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff), - "%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, 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) - check(hp_after == hp_before - backlash_power, "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after]) - # 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 - check(alert_ok, "blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)") - # 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) - check(settlement.action_economy.pending_ap_delta() == d0 - 1, "action 型负向 → 下轮行动点 −1(排队)") - settlement._grant_routed({"reward_id": "ap_up", "cell_type": "action", "polarity": "positive", "display_name": "行动点", "power": 1}, "positive", "safe_open", Vector2i(0, 0), 1) - check(settlement.action_economy.pending_ap_delta() == d0, "action 型正负抵消 → 排队净值归零") - - -# 资源面断言: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 - check(digchain_bad == 0, "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" % digchain_bad) - check(initial_bad == 0 and initial_count >= 1, "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" % initial_count) - check(mine_with_resource >= 1, "危险格揭示携带资源(mine ×resource=true %d——危险格不再恒无)" % mine_with_resource) - # 封锁格无身份 / 危险格未开恒可查(未开格恒可查——秘宝 桥接结构前提) - 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 - check(settlement.query_reward_identity(r0_first) == null, "封锁格(R₀)身份查询 = null(不入摇号清单)") - check(mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null, - "未开危险格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)") - - -# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。 -func _smoke_settlement_segments() -> void: - print("--- 五段全序 + 行型封闭 ---") - check(Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER - and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"], - "segment_order 五段冻结在位(表值 = 拍板序恒等)") - var run = _run_battle(1, 0) - var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0} - for row in run.stream: - if legacy.has(String(row.get("type", ""))): - legacy[String(row.get("type", ""))] += 1 - var legacy_total := 0 - for k in legacy: - legacy_total += int(legacy[k]) - check(legacy_total == 0, "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)") - var order_ok := true - var decay_verdict_ok := true - var by_round := {} - for row in run.stream: - var ri: int = int(row.get("round_index", 0)) - if not by_round.has(ri): - by_round[ri] = [] - by_round[ri].append(row) - for ri in by_round.keys(): - var phases: Array = [] - for row in by_round[ri]: - phases.append(_row_phase(row, ri)) - var max_phase := 0 - for p in phases: - if int(p) < max_phase: - order_ok = false - max_phase = maxi(max_phase, int(p)) - var decays := 0 - var verdicts := 0 - for row in by_round[ri]: - if String(row.get("type", "")) == "decay": - decays += 1 - if String(row.get("type", "")) == "verdict": - verdicts += 1 - if decays != 1 or verdicts != 1: - decay_verdict_ok = false # 衰减/判定每结算恰一次 - check(order_ok, "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)") - # 案C 段内序:段内伤害序 = 主角先手 →〔连珠矢随先手段〕→ 敌方逐个 → 地图账殿后。 - # 地图账 = 全部棋盘来源伤害行 + 主角倒下不兑留痕行;违序(先于先手或插进敌方还手中间)即失败。 - var mapdmg_ok := true - for ri_key in by_round.keys(): - var rows_r: Array = by_round[ri_key] - var first_hero := -1 - var last_enemy := -1 - for i in rows_r.size(): - var rw: Dictionary = rows_r[i] - if String(rw.get("type", "")) != "damage": - continue - if String(rw.get("source_class", "")) == "hero_attack" and first_hero < 0: - first_hero = i - elif String(rw.get("source_class", "")) == "enemy_action": - last_enemy = i - for i in rows_r.size(): - var rw: Dictionary = rows_r[i] - var sc := String(rw.get("source_class", "")) - var is_board_acct: bool = (String(rw.get("type", "")) == "damage" \ - and not ["hero_attack", "enemy_action", "fx_streak_segments"].has(sc)) \ - or String(rw.get("forfeit", "")) == "hero_down" - if not is_board_acct: - continue - if (first_hero >= 0 and i < first_hero) or (last_enemy >= 0 and i < last_enemy): - mapdmg_ok = false - check(mapdmg_ok, "案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账;连珠矢随先手段)") - check(decay_verdict_ok, "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)") - - -# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定 -func _row_phase(row: Dictionary, ri: int) -> int: - match String(row.get("type", "")): - "decay": - return 3 - "verdict": - return 4 - "cast": - return 2 - "damage": - return 2 # 案C:全部伤害兑付=战斗段(主角先手/敌方还手/地图账殿后统一结账) - "reward_ineffective": - return 2 if String(row.get("forfeit", "")) == "hero_down" else 0 # 战斗段末地图账不兑留痕 vs 授予段未生效 - match String(row.get("source_class", "")): - "safe_cell", "mine_hit", "seal_hit", "seal_miss", "clear_reward": - if int(row.get("round_index", ri)) < ri: - return 1 # 前轮授予实例的续存组合行(增益/减益段) - return 0 - _: - return 0 - - -# 清图驱动(真实流):标一面真雷封印(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。 -# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。 -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 全字段消费面) -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 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。 - -func _smoke_skill_pool() -> void: - print("--- 敌人技能池(开轮摇号 · 冷却/保底两闸 · 三乘法链 · 独立子流)---") - _smoke_skill_pool_data() - _smoke_skill_pool_replay_independence() - _smoke_skill_pool_ev_anchor() - _smoke_skill_pool_death_void() - _smoke_skill_pool_validation_reject() - _smoke_debuff_rider() - - -# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言) -func _smoke_skill_pool_data() -> void: - check(_db.skill.rows.size() == 14, "技能表十四行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕)") - var basic: Dictionary = _db.skill.row_by_id("attack") - check(not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0 - and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)), - "普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)") - var lunge: Dictionary = _db.skill.row_by_id("lunge") - check(not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2, - "突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)") - var charge_row: Dictionary = _db.skill.row_by_id("charge") - check(not charge_row.is_empty() and String(charge_row.buff_ref) == "empower", - "蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)") - var bite: Dictionary = _db.skill.row_by_id("bite") - check(not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1, - "撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)") - var venom: Dictionary = _db.skill.row_by_id("venom_burst") - check(not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4 - and is_equal_approx(float(venom.rider_mult), 0.75), - "毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)") - var venom_sole_ok := true - for row in _db.enemy.rows: - for entry in row.pool: - if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider": - venom_sole_ok = false - check(venom_sole_ok, "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)") - # 妨害技首发行 + off_guard 落点 + 实装面收窄 - var snare: Dictionary = _db.skill.row_by_id("web_snare") - check(not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard" - and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4, - "蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)") - var snare_sole_ok := true - for row in _db.enemy.rows: - for entry in row.pool: - if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider": - snare_sole_ok = false - check(snare_sole_ok, "蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)") - var off_guard_row: Dictionary = _db.status_effect_row("off_guard") - check(not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac", - "失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)") - # 普通敌错峰两新行——毒吐/盾击(V3) - var spit: Dictionary = _db.skill.row_by_id("venom_spit") - check(not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \ - and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult"), - "毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)") - var bash: Dictionary = _db.skill.row_by_id("shield_bash") - check(not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \ - and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult"), - "盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)") - var spider_lead_band := _first_encounter_band(2, "giant_spider") # 首遇带数据驱动定位(契约 8) - var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇带不投(Q6——教学位池零新招) - var bash_sole_ok := true # 盾击只挂灰矮人族三行 - for row in _db.enemy.rows: - for entry in row.pool: - var sid := String(entry.skill_id) - if sid == "venom_spit" and not (String(row.enemy_id) == "giant_spider" and int(row.level_index) == 2 \ - and int(row.depth_band) > spider_lead_band): - spit_sole_ok = false - if sid == "shield_bash" and not (String(row.enemy_id) == "duergar" and int(row.level_index) == 3): - bash_sole_ok = false - check(spit_sole_ok, "毒吐投放面(仅 depth_band > 巨蜘蛛首遇带(%d)的巨蜘蛛族行——首遇带池不动,迭代 24 N2/Q6)〔fv24〕" % spider_lead_band) - check(bash_sole_ok, "盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)") - # 首遇带行池恒 {attack:6, bite:2, charge:1}(去写死深度判据——带键口径) - var spider_lead_pool := {} - for row in _db.enemy.rows: - if int(row.level_index) == 2 and String(row.row_kind) == "battle" \ - and int(row.depth_band) == spider_lead_band and String(row.enemy_id) == "giant_spider": - for entry in row.pool: - spider_lead_pool[String(entry.skill_id)] = int(entry.weight) - check(spider_lead_pool == {"attack": 6, "bite": 2, "charge": 1}, - "巨蜘蛛首遇带行 pool 恒 {attack:6, bite:2, charge:1}(教学位池零新招——毒吐首投在更深的带)〔fv24〕") - # 行集键 = (level_index, row_kind, depth_band, enemy_id)——分支节点图批起 bugbear 等敌种跨关异池, - # enemy_id 不再唯一;同带混编多行时每行各一池(行序 = 文件序 = 契约序) - var expect_pools := { - "1_battle_0_goblin": {"attack": 9, "lunge": 1}, - "1_battle_1_goblin": {"attack": 9, "lunge": 1}, - "1_battle_2_bugbear": {"attack": 6, "charge": 2, "heavy": 2}, - "1_battle_3_hobgoblin": {"attack": 6, "charge": 2, "heavy": 2}, - "1_boss_4_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, - "2_battle_0_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, - "2_battle_1_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, - "2_battle_4_giant_spider": {"attack": 6, "bite": 2, "charge": 1}, - "2_battle_5_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, - "2_battle_5_bugbear": {"attack": 5, "lunge": 2, "heavy": 2}, - "2_battle_6_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, - "2_battle_7_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, - "2_boss_9_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, - "3_battle_0_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, - "3_battle_1_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, - "3_battle_4_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, - "3_battle_5_duergar": {"attack": 5, "lunge": 2, "heavy": 2, "shield_bash": 2}, - "3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, - "3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, - "3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, - } - var pools_ok := true - for row in _db.enemy.rows: - var rk := "%d_%s_%d_%s" % [int(row.level_index), String(row.row_kind), int(row.depth_band), String(row.enemy_id)] - if not expect_pools.has(rk): - pools_ok = false - continue - var got := {} - for entry in row.pool: - got[String(entry.skill_id)] = int(entry.weight) - if got != expect_pools[rk]: - pools_ok = false - check(pools_ok, "二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)") - var basic_count_ok := true - for row in _db.enemy.rows: - var n := 0 - for entry in row.pool: - var srow: Dictionary = _db.skill_row(String(entry.skill_id)) - if not srow.is_empty() and String(srow.type) == "basic": - n += 1 - if n != 1: - basic_count_ok = false - check(basic_count_ok, "每池恰一行普攻(空池不可能的加载校验同款)") - # 池构成纪律复查(V4):零违反——增益 ≤1 / 妨害 ≤1(敌人系统 v0.13 结构冻结;载入期 - # validate_skill_pool_links 已硬闸,此处冒烟侧显式复核新投放后仍绿) - var comp_ok := true - for row in _db.enemy.rows: - var n_buff := 0 - var n_debuff := 0 - for entry in row.pool: - var srow: Dictionary = _db.skill_row(String(entry.skill_id)) - if srow.is_empty(): - continue - if String(srow.type) == "buff": - n_buff += 1 - elif String(srow.type) == "debuff": - n_debuff += 1 - if n_buff > 1 or n_debuff > 1: - comp_ok = false - check(comp_ok, "逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)") - # Boss 半血解锁技·编排集成断言(真实编排回路:半血 → 下轮开轮闩检查 → 解锁行恰一次) - var st_unlock: Dictionary = {"rows": []} - var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242)) - sett_u.step_row.connect(func(row): st_unlock["rows"].append(row)) - check(sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)") - var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面) - sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65)) - check(int(boss_u.hp) == 65, "前置:Boss 压到阈值 65(=round(130×0.5))") - sett_u.minesweeper.settle_batch([{"action": "stop"}]) # 收轮 → 结算 → 次轮开轮闩检查 - var unlock_rows: Array = [] - for r in st_unlock["rows"]: - if String(r.get("highlight", "")) == "skill_unlock": - unlock_rows.append(r) - check(unlock_rows.size() == 1, "解锁归因行恰一次(×%d——闩去重,二次检查零行)" % unlock_rows.size()) - if unlock_rows.size() == 1: - check(String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒", - "解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)") - check(String(unlock_rows[0].get("type", "")) == "highlight", "解锁行型 = highlight 既有行型(零行型新增——行协议封闭)") - var rollable_ok := true - for depth in [0, 1, 2, 3, 4]: - var es = load("res://core/unit_container.gd").new(_db) - es.spawn(1, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(1, int(depth))) - for u in es.all_enemies(): - if es.rollable_skills(u).is_empty(): - rollable_ok = false - for depth in [0, 1, 4, 5, 6, 7, 8]: - var es2 = load("res://core/unit_container.gd").new(_db) - es2.spawn(2, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(2, int(depth))) - for u in es2.all_enemies(): - if es2.rollable_skills(u).is_empty(): - rollable_ok = false - check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)") - # 状态表新行 + 敌方防御池映射 - var empower: Dictionary = _db.status_effect_row("empower") - var fortify: Dictionary = _db.status_effect_row("fortify") - check(not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100, - "蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)") - check(not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50, - "硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)") - check(String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \ - and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense", - "effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)") - - -# ② 同种子回放逐位复现(D3 大地精,多实例同种) -# 单敌轨迹不独立复放——轨迹取数与其它流交错,无法脱离全序重建; -# 同种子全战斗逐位复现由 ② 全对拍承载,单点定位归事件日志。 -func _smoke_skill_pool_replay_independence() -> void: - var seed_val := -1 - for s in range(1, 60): - var seqs := _record_pool_trajectories(s, 1) - if not seqs.is_empty() and int(seqs.count) >= 2: - seed_val = s - break - check(seed_val > 0, "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val) - # 敌 hp 低(表值 6)时 hero_atk=1 十轮内必然击杀(实例错位、轨迹断裂)—— - # 观测窗取 5 轮(5 拍 ×1 伤 < 6 HP 全员存活;仍覆盖保底 M=4 的触发窗)。 - var rounds := 5 - var run1 := _record_pool_trajectories(seed_val, rounds) - var run2 := _record_pool_trajectories(seed_val, rounds) - check(not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs), - "同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" % [rounds, int(run1.count)]) - - -# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数) -func _record_pool_trajectories(seed_val: int, rounds: int) -> Dictionary: - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(seed_val) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": 300, "hero_max_hp": 300, "hero_atk": 1}): - return {} - var seqs: Array = [] - for u in settlement._enemy_system.all_enemies(): - seqs.append([]) - for r in rounds: - var handles: Array = settlement._enemy_system.all_enemies() - for i in handles.size(): - seqs[i].append(settlement._enemy_system.pending_action(handles[i])) - settlement.minesweeper.settle_batch([{"action": "stop"}]) - if settlement.battle_result != "": - return {} - return {"count": seqs.size(), "seqs": seqs} - - -# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。 -# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带; -# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。 -func _smoke_skill_pool_ev_anchor() -> void: - var bands := {"goblin": [1.00, 1.20], "bugbear": [0.97, 1.03], "ogre_chief": [1.10, 1.30]} # 酋长重锚(震地怒击半血解锁入池,含闸长期速率上移:实测 1.1948;EV 锚注 enemy.tres) - var species := ["goblin", "bugbear", "ogre_chief"] - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(20260921) - var n_beats := 5000 - var pity_m: int = int(_db.skill.pity_threshold) - for si in species.size(): - var eid: String = species[si] - var template: Array = [] - for row in _db.enemy.rows: - if String(row.enemy_id) == eid: - template = row.pool - break - var pool: Array = [] - for entry in template: - pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0}) - var streak := 0 - var ev_sum := 0.0 - for k in n_beats: - var entries: Array = [] - for e in pool: - if int(e.cooldown_left) <= 0: - entries.append({"skill_id": e.skill_id, "weight": e.weight}) - if streak >= pity_m: - var specials: Array = [] - for e in pool: - var sr: Dictionary = _db.skill_row(String(e.skill_id)) - if not sr.is_empty() and String(sr.type) != "basic": - specials.append({"skill_id": e.skill_id, "weight": e.weight}) - if not specials.is_empty(): - entries = specials - var sid := SkillLottery.draw(entries, rng.open_stream("behavior_roll")) - var srow: Dictionary = _db.skill_row(sid) - var mult := 0.0 - if String(srow.type) == "basic": - mult = 1.0 - elif String(srow.type) == "damage": - mult = float(srow.get("damage_mult", 1.0)) - ev_sum += mult - for e in pool: - e.cooldown_left = maxi(0, int(e.cooldown_left) - 1) - if String(e.skill_id) == sid: - e.cooldown_left = int(srow.cooldown_beats) - streak = streak + 1 if String(srow.type) == "basic" else 0 - var measured: float = ev_sum / float(n_beats) - var band: Array = bands[eid] - var in_band: bool = measured >= float(band[0]) and measured <= float(band[1]) - check(in_band, "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" % [eid, measured, float(band[0]), float(band[1]), n_beats]) - - -# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账) -func _smoke_skill_pool_death_void() -> void: - var void_seed := -1 - for s in range(1, 400): - var rg = load("res://core/rng_service.gd").new() - rg.set_master_seed(s) - var erow: Dictionary = _db.resolve_enemy_rowset(1, 2, "battle")[0] # 行集首行(战斗节点 battle 键域) - var entries: Array = [] - for entry in erow.pool: - entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)}) - var sid := SkillLottery.draw(entries, rg.open_stream("behavior_roll")) - if String(_db.skill_row(sid).type) != "basic": - void_seed = s - break - check(void_seed > 0, "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed) - var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(void_seed) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - var rows: Array = [] - settlement.step_row.connect(func(row): rows.append(row)) - check(settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}), - "死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)") - var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读) - var pending: String = settlement._enemy_system.pending_action(u) - check(pending != "", "开轮已摇定本拍动作(待发 = %s)" % pending) - settlement.minesweeper.settle_batch([{"action": "stop"}]) - var enemy_rows := 0 - for row in rows: - if String(row.get("source_class", "")) == "enemy_action": - enemy_rows += 1 - check(enemy_rows == 0, "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)") - var cd_sum := 0 - for e in u.pool: - cd_sum += int(e.cooldown_left) - check(cd_sum == 0 and int(u.basic_streak) == 0, - "作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)") - - -# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律) -func _smoke_skill_pool_validation_reject() -> void: - var skill_table = load("res://data/schemas/skill_table.gd").new() - var enemy_table = load("res://data/schemas/enemy_table.gd").new() - enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}]) - check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"), - "加载校验拒绝:池条目 skill_id 悬空致命") - skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}]) - check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"), - "加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)") - skill_table.rows.assign([ - {"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3}, - {"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}]) - enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}]) - check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"), - "加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)") - skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}]) - check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)") - skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}]) - check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)") - skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 30.0, "cooldown_beats": 1}]) - check(not skill_table.validate().is_empty(), "加载校验拒绝:damage_mult 越域 [1.1,16]") - var et2 = load("res://data/schemas/enemy_table.gd").new() - et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "a", "weight": 0}]}]) - check(not et2.validate().is_empty(), "加载校验拒绝:池权重 ≤ 0") - et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": []}]) - check(not et2.validate().is_empty(), "加载校验拒绝:空池(禁)") - # 妨害 / 必中附加分量校验组 - skill_table.rows.assign([ - {"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}, - {"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}]) - enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}]) - check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"), - "加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)") - skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}]) - check(not skill_table.validate().is_empty(), "加载校验拒绝:妨害技缺 buff_ref(效果引用)") - skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}]) - check(not skill_table.validate().is_empty(), "加载校验拒绝:rider_mult 越域 (0,1]") - skill_table.rows.assign([{"skill_id": "b", "display_name": "b", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "rider_mult": 0.5, "cooldown_beats": 1}]) - check(not skill_table.validate().is_empty(), "加载校验拒绝:非伤害技带必中附加分量") - # 施放类 side 守卫:妨害技引用敌方侧状态 → 拒绝(增益技引用主角侧同罪,本组以妨害反例覆盖) - skill_table.rows.assign([ - {"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0}, - {"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}]) - enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}]) - check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side"), - "加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)") - - -func _has_err_category(errs: Array, category: String) -> bool: - for e in errs: - if String(e.get("category", "")) == category: - return true - return false - - -# ⑦ 实装探针(妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔): -# 停手轮驱动 (2,9)——零挖掘零封印 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行 -# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。 -func _smoke_debuff_rider() -> void: - var venom_seed := -1 - var venom_check_ok := true - var venom_rows_seen := 0 - var snare_seed := -1 - var base_ac: int = int(_db.hero.armor_class) - var off_m: int = int(_db.status_effect_row("off_guard").magnitude) - var floor_v: int = int(_db.settlement.damage_floor) - for s in range(1, 401): - if venom_seed > 0 and snare_seed > 0: - break - var rg = load("res://core/rng_service.gd").new() - rg.set_master_seed(s) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg) - var rows: Array = [] - settlement.step_row.connect(func(row): rows.append(row)) - if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): - continue - var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放) - for i in range(6): - if settlement.battle_result != "": - break - settlement.minesweeper.settle_batch([{"action": "stop"}]) - var has_venom := false - var has_snare := false - var snare_round := -1 - for row in rows: - if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action": - if String(row.get("type", "")) == "cast" and String(row.get("skill_id", "")) == "web_snare": - has_snare = true - snare_round = int(row.get("round_index", -1)) - continue - if String(row.get("skill_id", "")) == "venom_burst": - has_venom = true - venom_rows_seen += 1 - var rider_v: int = int(row.get("rider", -1)) - if rider_v != maxi(floor_v, int(floor(float(boss_atk) * 0.75))): - venom_check_ok = false - # 终值精确复算(停手轮池恒空):主分量链结果 + 必中附加后置 - var base_factor: float = float(int(row.tier)) * (float(_db.settlement.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0) - var rolled_main: int = int(floor(float(int(row.baseline)) * base_factor / 100.0)) - var expected: int = maxi(floor_v, rolled_main + int(row.get("attach", 0))) + rider_v - if int(row.value) != expected: - venom_check_ok = false - elif row.has("rider"): - venom_check_ok = false # rider 键只允许出现在带 rider_mult 的技能行 - if has_snare: - # 施放后主角掷点阈值下调(off_guard ac 轴):同轮起的伤害行阈值读数 = 基准 AC + 失衡幅度。 - # 只取"可观测"种子(施放轮后仍有敌方伤害行——施放在末轮随战斗终局属合法不可观测,不作失败)。 - var seen_lowered := false - for row in rows: - if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action": - continue - if int(row.get("round_index", -1)) >= snare_round and int(row.get("ac", -999)) == base_ac + off_m: - seen_lowered = true - break - if seen_lowered and snare_seed < 0: - snare_seed = s - if has_venom and venom_seed < 0: - venom_seed = s - check(venom_seed > 0 and venom_rows_seen >= 1, "毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" % [venom_seed, venom_rows_seen]) - check(venom_check_ok, "毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)") - check(snare_seed > 0, "蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" % snare_seed) - - -# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域图版行进(关卡节点地图 v1.0 图版)---- - -func _smoke_level_graph() -> void: - print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") - var autoplay = load("res://smoke/level_autoplay.gd") - # ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述) - check(_db.level.validate().is_empty(), "LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误") - # ② L2 行集结构(网状图建议规模) - var l2 := {} - for row in _db.level.rows: - if int(row.level_index) == 2: - l2 = row - check(not l2.is_empty() and _level_active_nodes(2).size() == 10, "L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)") - var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} - var branch := 0 - for node in _level_active_nodes(2): - tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1 - if node.get("next", []).size() >= 2: - branch += 1 - check(int(tcount.battle) == 6 and int(tcount.boss) == 1, "L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)") - check(int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1, "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)") - check(branch >= 2, "L2 真实岔路 = %d 处(≥2)" % branch) - # ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法) - var lt = load("res://data/schemas/level_table.gd").new() - var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]}, - {"node_depth": 2, "type": "boss"}] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}]) - check(lt.validate().is_empty(), "图校验正例:两岔合流小图零错误") - var dup = JSON.parse_string(JSON.stringify(good)) - dup[1].node_depth = 0 - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}]) - check(_has_err_category(lt.validate(), "dup_key"), "图校验拒绝:节点编号重复(关卡内唯一定位地址)") - var dual = JSON.parse_string(JSON.stringify(good)) - dual.append({"node_depth": 9, "type": "battle", "next": [2]}) - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}]) - check(_has_err_category(lt.validate(), "structural"), - "图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)") - var boss_out = JSON.parse_string(JSON.stringify(good)) - boss_out[2].next = [1] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}]) - check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:Boss 带出边(终点不可外出)") - var cyc = JSON.parse_string(JSON.stringify(good)) - cyc[1].next = [0] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}]) - check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)") - var dang = JSON.parse_string(JSON.stringify(good)) - dang[0].next = [1, 7] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}]) - check(_has_err_category(lt.validate(), "dangling_next"), "图校验拒绝:出边指向不存在编号(悬空致命)") - var orphan = JSON.parse_string(JSON.stringify(good)) - orphan[0].next = [1] - orphan[1].next = [2] - orphan.append({"node_depth": 5, "type": "battle", "next": [2]}) - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}]) - var orphan_errs: Array = lt.validate() - check(_has_err_category(orphan_errs, "structural"), "图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)") - # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) - # 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体) - var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) - check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result) - check(win_min.boss_depth == 9, "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)") - check(win_min.visited_ids == [0, 1, 3, 7, 8, 9], "min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids)) - check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices) - # max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表 - # 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300) - # 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + - # 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次 - # 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。 - const MAX_PATH_SEED := 43 # 种子重采程序:同款循环首命中(断言注释既定程序,临时脚本用后即删) - var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0) - check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9], - "L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED) - # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言) - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) - runner.set_hero_override(200, 100) - var choice_sizes: Array = [] - runner.choices_presented.connect(func(c): choice_sizes.append(c.size())) - check(runner.start_level(2, 42, {}, 0), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)") - var st = _battle_st("normal") - var first = runner.current_battle() - var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变) - first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", ""))) - while runner.current_battle() == first and not res_box.has("result"): - autoplay._autoplay_battle(runner, first, st, null) - check(String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice(), - "首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)") - check(runner.next_candidate_ids() == [1, 2], "入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)") - check(not runner.pick_next_node(8) and runner.awaiting_choice(), "候选外编号拒绝且仍待选(拒绝语义)") - check(runner.pick_next_node(2), "选编号 2(奖励点)受理") - check(String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice(), - "到达奖励点:直通兑付(确认前不出选路口)") - check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6], - "兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]") - check(runner.pick_next_node(6) and runner.current_battle() != null, "选编号 6(巨蜘蛛巢群)开战") - # ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描 - # (绿档口径 = 12 局多数胜 + 零踩雷恒要求;还原全胜档需回调锚行血量,归用户决策) - var green_ok := true - var green_min_hp := 99999 - var green_wins := 0 - for i in range(12): - var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) - g_runner.start_level(2, 9100 + i) - var g_st = _battle_st("perfect") - var g_first = g_runner.current_battle() - var g_box := {} - g_first.battle_finished.connect(func(p): - g_box["result"] = String(p.get("result", "")) - g_box["hero_hp"] = int(p.get("hero_hp", 0))) - var g_guard := 6 - while g_runner.current_battle() == g_first and not g_box.has("result") and g_guard > 0: - g_guard -= 1 - autoplay._autoplay_battle(g_runner, g_first, g_st, null) - if int(g_st.mine_hits) > 0: - green_ok = false - if String(g_box.get("result", "")) == "victory": - green_wins += 1 - if int(g_box.get("hero_hp", 0)) > 0: - green_min_hp = mini(green_min_hp, int(g_box.hero_hp)) - check(green_ok, "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷") - check(green_wins >= 7, "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins) - check(green_min_hp > 0, "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp) - # ⑦ 跨表行完整:L2 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(加载校验同款显式复述; - # enemy 键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域) - var bg_keys := {} - for row in _db.board_gen.rows: - bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true - var en_keys := {} - for row in _db.enemy.rows: - en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true - var cross_ok := true - for node in _level_active_nodes(2): - var t := String(node.get("type", "")) - if t != "battle" and t != "boss": - continue - if not bg_keys.has("2_%d" % int(node.node_depth)) or not en_keys.has("2_%s_%d" % [t, int(node.node_depth)]): - cross_ok = false - check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (2,row_kind,depth_band))") - # ⑦a 定点:L2 首遇带行集与 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; - # 断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) - var l2_spider_band: int = _first_encounter_band(2, "giant_spider") - var l2_spider_set: Array = _db.resolve_enemy_rowset(2, l2_spider_band, "battle") - var l2_spider_ids := {} - for r in l2_spider_set: - l2_spider_ids[String(r.get("enemy_id", ""))] = true - check(l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \ - and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max"), - "巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" % l2_spider_band) - var l2_boss_set: Array = _db.resolve_enemy_rowset(2, _boss_depth(2), "boss") - var l2_boss_ids := {} - for r in l2_boss_set: - l2_boss_ids[String(r.get("enemy_id", ""))] = true - check(l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward", - "蛛化卓尔精灵 Boss 行集(2,boss,%d)= {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % _boss_depth(2)) - # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: - # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) - var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") - var mix_shape_ok: bool = l2_mix_set.size() == 2 \ - and String(l2_mix_set[0].get("enemy_id", "")) == "bugbear" \ - and String(l2_mix_set[1].get("enemy_id", "")) == "giant_spider" - check(mix_shape_ok, - "首批混编行集 (2,battle,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)〔fv18〕") - var mix_sum_max := 0 - for r in l2_mix_set: - mix_sum_max += int(r.get("unit_count_max", int(r.get("unit_count", 1)))) - check(mix_sum_max <= 4, - "首批混编行集 (2,battle,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])〔fv19〕" % mix_sum_max) - check(int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \ - and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2, - "首批混编新行 (2,battle,5,giant_spider) 行值 = 复制巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)") - # ---- L3 灰矮人矿城断言段 ---- - # ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处) - var l3 := {} - for row in _db.level.rows: - if int(row.level_index) == 3: - l3 = row - check(not l3.is_empty() and _level_active_nodes(3).size() == 10, "L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)") - var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} - var branch3 := 0 - for node in _level_active_nodes(3): - tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1 - if node.get("next", []).size() >= 2: - branch3 += 1 - check(int(tcount3.battle) == 6 and int(tcount3.boss) == 1, "L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)") - check(int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)") - check(branch3 == 3, "L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3) - # ⑨ 跨表行完整:L3 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用—— - # 键域同形:(L,row_kind,depth_band)) - var cross_ok3 := true - for node in _level_active_nodes(3): - var t3 := String(node.get("type", "")) - if t3 != "battle" and t3 != "boss": - continue - if not bg_keys.has("3_%d" % int(node.node_depth)) or not en_keys.has("3_%s_%d" % [t3, int(node.node_depth)]): - cross_ok3 = false - check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (3,row_kind,depth_band))") - # ⑩ 定点:L3 首遇带行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) - var l3_lead_band: int = _first_encounter_band(3, "duergar") - var l3_lead_set: Array = _db.resolve_enemy_rowset(3, l3_lead_band, "battle") - var l3_lead_ids := {} - for r in l3_lead_set: - l3_lead_ids[String(r.get("enemy_id", ""))] = true - check(l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \ - and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \ - and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max"), - "灰矮人首遇带(3,battle,%d——扫表取最低带)行集 = {duergar} 恰一行、首行重甲、固定 1 只不加骰〔fv23〕" % l3_lead_band) - var l3_king_set: Array = _db.resolve_enemy_rowset(3, _boss_depth(3), "boss") - var l3_king_ids := {} - for r in l3_king_set: - l3_king_ids[String(r.get("enemy_id", ""))] = true - check(l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy", - "灰矮人石君王 Boss 行集(3,boss,%d)= {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % _boss_depth(3)) - # ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过 - # (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥) - var green3_ok := true - var green3_min_hp := 99999 - for i in range(12): - var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) - g3_runner.set_hero_override(200, 100) - g3_runner.start_level(3, 9100 + i) - var g3_st = _battle_st("perfect") - var g3_first = g3_runner.current_battle() - var g3_box := {} - g3_first.battle_finished.connect(func(p): - g3_box["result"] = String(p.get("result", "")) - g3_box["hero_hp"] = int(p.get("hero_hp", 0))) - var g3_guard := 6 - while g3_runner.current_battle() == g3_first and not g3_box.has("result") and g3_guard > 0: - g3_guard -= 1 - autoplay._autoplay_battle(g3_runner, g3_first, g3_st, null) - if String(g3_box.get("result", "")) != "victory" or int(g3_st.mine_hits) > 0: - green3_ok = false - elif int(g3_box.get("hero_hp", 0)) > 0: - green3_min_hp = mini(green3_min_hp, int(g3_box.hero_hp)) - check(green3_ok, "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷") - check(green3_min_hp > 0, "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green3_min_hp) - - # ⑫ 混编行集运行面正反断言:注入带键 (1,battle,5)/(1,battle,6) = - # L1 节点图与全部既有扫描面之外的新带键(带命中精确取注入行集——新 L 会被 ceil 封顶到 L3 不可用), - # 断言后立即还原表,后续断言面零污染。R3 fail-fast 正反验证 + R6 行序运行面验证。 - var mix_row_a := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "goblin", "display_name": "地精", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} - var mix_row_b := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 2, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]} - _db.enemy.rows.append(mix_row_a) - _db.enemy.rows.append(mix_row_b) - var mix_sys = load("res://core/unit_container.gd").new(_db) - check(mix_sys.spawn(1, 5, int(_db.hero.max_hp)), "混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)") - var mix_units: Array = mix_sys.all_enemies() - check(mix_units.size() == 3, "混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)") - check(String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear", - "混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)") - check(String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3, - "混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)") - _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) - _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) - var over_sys = load("res://core/unit_container.gd").new(_db) - check(not over_sys.spawn(1, 6, int(_db.hero.max_hp)), "行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)") - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - check(int(_db.enemy.rows.size()) == 20, "混编断言段注入行全量还原(表回 20 行——首批混编投放后基线行数,迭代 18 卡⑤;后续断言面零污染)") - - # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / - # Boss 恒可见 / 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 - var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) - var vis_ledger := {"hero_hp": 10, "gp": 0} - var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true, 0) - check(vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1, - "可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)") - var vis_ids: Array = vis_runner.type_visible_ids() - var vis_cand_in := true - for cid in vis_runner.next_candidate_ids(): - if not vis_ids.has(int(cid)): - vis_cand_in = false - check(vis_cand_in, "可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕") - check(vis_ids.has(_boss_depth(2)), "可见性冒烟:L2 Boss(编号 %d)恒在可见集(终点不入雾)〔fv3〕" % _boss_depth(2)) - var vis_n: int = int(_db.level.info_visible_layers) - check(vis_n == 2, "可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n) - check(not vis_ids.has(7) and not vis_ids.has(8), - "可见性冒烟:入口锚层 0 时层距 3/4 的节点 7/8 入雾(非豁免——入口 BFS 层差 > N)〔fv1〕") - - -# 冒烟隔离用全新 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 冒险钱包·到账 ---- - -func _smoke_gp_wallet() -> void: - print("--- GP 冒险钱包·到账(战斗完成入账恰一次 + Boss 加值)---") - var autoplay = load("res://smoke/level_autoplay.gd") - # ① L1 首战(普通战档——掷定 [2,4] + 关级加值 0):runner 驱动,收集 battle_finished 与钱包事件 - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) - runner.set_hero_override(200, 100) - var fin := {} - var wallet_events: Array = [] - runner.wallet_changed.connect(func(ev, gp_val, extra): wallet_events.append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))})) - check(runner.start_level(1, 42), "GP 到账:L1 启动(普通战档)") - var battle = runner.current_battle() - var rows: Array = [] - battle.step_row.connect(func(row): rows.append(row)) - battle.battle_finished.connect(func(p): fin["p"] = p) - var st := _battle_st("normal") - var guard := 30 - while runner.current_battle() == battle and not fin.has("p") and guard > 0: - guard -= 1 - autoplay._autoplay_battle(runner, battle, st, null) - check(fin.has("p") and String(fin["p"].get("result", "")) == "victory", "L1 首战胜利(battle_finished gp_dropped 载荷在案)") - var escrow_sum := _escrow_sum_of(rows) - var gp_dropped: int = int(fin["p"].get("gp_dropped", -999)) - check(gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4, - "L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" % [gp_dropped - escrow_sum, escrow_sum]) - var gains: Array = [] - for ev in wallet_events: - if ev["event"] == "gain": - gains.append(ev) - check(gains.size() == 1, "钱包恰入账一次(gain 事件 ×%d)" % gains.size()) - if gains.size() == 1: - check(int(gains[0]["amount"]) == gp_dropped, "入账金额 = gp_dropped 载荷(%d)" % gp_dropped) - check(runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"]), - "钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" % runner.gp) - # ② L2 Boss 战(节点 9——商店房批重编号):掷定 [6,10] + 关级加值 1 - var boss = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(42)) - var rows_b: Array = [] - var fin_b := {} - boss.step_row.connect(func(row): rows_b.append(row)) - boss.battle_finished.connect(func(p): fin_b["p"] = p) - check(boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)}), - "L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)") - var guard_b := 60 - while boss.battle_result == "" and guard_b > 0: - guard_b -= 1 - boss.minesweeper.settle_batch([{"action": "stop"}]) - check(fin_b.has("p"), "L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" % String(boss.battle_result)) - var escrow_b := _escrow_sum_of(rows_b) - var gp_b: int = int(fin_b.get("p", {}).get("gp_dropped", -999)) if fin_b.has("p") else -999 - check(gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11, - "L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" % [gp_b - escrow_b, escrow_b]) - - -# 步骤流中 GP 型兑付行(effect_id gp_gain/gp_loss)的 value 代数和 = escrow 会计算(归因行随行携带) -func _escrow_sum_of(rows: Array) -> int: - var sum := 0 - for row in rows: - if String(row.get("effect_id", "")) == "gp_gain" or String(row.get("effect_id", "")) == "gp_loss": - sum += int(row.get("value", 0)) - return sum - - -# ---- ② escrow 隔离(GP安全格兑付 / 破财直验 / 钱包 clamp)---- - -func _smoke_gp_escrow() -> void: - print("--- GP escrow 隔离(GP安全格兑付入缓冲 · 破财不透支)---") - # ① 种子扫描:找GP 袋(gp 型安全格)可挖掘局——挖开兑付 +3 入 escrow,战斗末随 gp_dropped 一次上抛 - var scan := {} - for s in range(1, 121): - var rng = _fresh_rng(s) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - var rows: Array = [] - settlement.step_row.connect(func(row): rows.append(row)) - var fin := {} - settlement.battle_finished.connect(func(p): fin["p"] = p) - if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}): - continue - var target := _find_gp_dig(settlement.minesweeper, settlement) - if target.is_empty(): - continue - scan = {"seed": s, "settlement": settlement, "target": target, "rows": rows, "fin": fin} - break - check(not scan.is_empty(), "GP 袋(gp 型安全格)可挖掘局种子可达(seed=%s)" % str(scan.get("seed", -1))) - if not scan.is_empty(): - var settlement2 = scan["settlement"] - var ms = settlement2.minesweeper - var t: Dictionary = scan["target"] - var dig_arr: Array = [t["dig_cell"].x, t["dig_cell"].y] - if t["hero_cell"] != ms.hero_pos(): - ms.settle_batch([{"action": "move", "cell": t["hero_cell"]}]) - ms.settle_batch([{"action": "dig", "cell": t["dig_cell"]}]) - var guard_stop := 12 - while settlement2.battle_result == "" and guard_stop > 0: - guard_stop -= 1 - ms.settle_batch([{"action": "stop"}]) # 收轮结算:授予段兑付 +3 → 战斗段先手 999 清场 → 胜利上抛 - check(settlement2.battle_result == "victory", "GP局战斗终局(先手 999 收束)") - var gain_ok := false - for row in scan["rows"]: - if String(row.get("effect_id", "")) == "gp_gain" and row.get("origin_cell", []) == dig_arr and int(row.get("value", 0)) == 3: - gain_ok = true - check(gain_ok, "GP 袋兑付行在案(GP 袋 +3 → 战斗内缓冲,origin 指回来源格)") - check(int(settlement2.escrow) >= 3, "escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" % int(settlement2.escrow)) - var gp_d: int = int(scan["fin"].get("p", {}).get("gp_dropped", -999)) - check(gp_d - int(settlement2.escrow) >= 2 and gp_d - int(settlement2.escrow) <= 4, - "格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" % [gp_d, int(settlement2.escrow)]) - # ② 破财(gp_loss)直验:兑付入口直调(weight 0 不入池——字典负向行单测通道,负向字典冒烟前例) - var rng_l = _fresh_rng(20260921) - var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l) - var fin_l := {} - settle_l.battle_finished.connect(func(p): fin_l["p"] = p) - check(settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"}), - "破财直验战斗启动") - for i in range(3): - settle_l._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, - "negative", "mine_hit", Vector2i(0, 0), 1) - check(int(settle_l.escrow) == -6, "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)") - settle_l.minesweeper.settle_batch([{"action": "stop"}]) - var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999)) - var boss_drop: Dictionary = _db.shop_gp_drop_row("boss") - var boss_bonus1: int = _db.shop_level_gp_bonus(1) - check(gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1, - "破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" % [ - gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1]) - # ③ 钱包不透支(clamp ≥0):runner 驱动 L1 首战 + 战斗中注入破财 → gp_dropped 负值 → 钱包钳制归 0 - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(4242)) - runner.set_hero_override(49850, 0) # atk 2 → 999(快速终局;hp 基线) - var ev_box := {"gains": [], "min_gp": 999999} - runner.wallet_changed.connect(func(ev, gp_val, extra): - ev_box["gains"].append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))}) - ev_box["min_gp"] = mini(ev_box["min_gp"], int(gp_val))) - check(runner.start_level(1, 4242), "clamp 场景:L1 启动(冒险钱包开局 0)") - var b3 = runner.current_battle() - var fin3 := {} - b3.battle_finished.connect(func(p): fin3["p"] = p) - for i in range(3): - b3._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, - "negative", "mine_hit", Vector2i(0, 0), 1) - var guard3 := 12 - while runner.current_battle() == b3 and not fin3.has("p") and guard3 > 0: - guard3 -= 1 - b3.minesweeper.settle_batch([{"action": "stop"}]) - var gp3: int = int(fin3.get("p", {}).get("gp_dropped", 999)) - check(gp3 < 0, "破财压制入账载荷为负(gp_dropped=%d)" % gp3) - var gain3: Array = [] - for ev in ev_box["gains"]: - if ev["event"] == "gain": - gain3.append(ev) - check(gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0, - "破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" % gp3) - check(runner.gp == 0 and int(ev_box["min_gp"]) >= 0, "钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" % [runner.gp, int(ev_box["min_gp"])]) - - -# 板真相直读找GP 袋挖掘目标:未开安全格 + gp 型资源身份 + 存在可达已开邻格(_find_seal_target 同款口径) -func _find_gp_dig(minesweeper, settlement) -> Dictionary: - var board = minesweeper.current_board() - var revealed := {} - for c in minesweeper.revealed_cells(): - revealed[c] = true - for y in board.height: - for x in board.width: - var c := Vector2i(x, y) - if revealed.has(c) or board.is_mine(c): - continue - var ident = settlement.query_reward_identity(c) - if ident == null or String(ident.get("cell_type", "")) != "gp": - continue - for n in board.neighbors8(c): - if revealed.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()): - return {"dig_cell": c, "hero_cell": n} - return {} - - -# ---- ③ 商店房三收口 ---- - -func _smoke_shop_room() -> void: - print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---") - var autoplay = load("res://smoke/level_autoplay.gd") - # 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) - var shop_run := {} - for s in range(42, 92): - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) - runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) - runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本 - var st := _battle_st("normal") - var guard := 40 - while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0: - guard -= 1 - if runner.awaiting_choice(): - var ids: Array = runner.next_candidate_ids() - ids.sort() - runner.pick_next_node(int(ids[0])) - continue - var t := String(runner.current_node().get("type", "")) - if t == "reward" or t == "rest": - runner.continue_after_node() - continue - if t == "shop": - break - var b = runner.current_battle() - if b == null: - break - var fin := {} - b.battle_finished.connect(func(p): fin["p"] = p) - var g2 := 80 - while runner.current_battle() == b and not fin.has("p") and g2 > 0: - g2 -= 1 - autoplay._autoplay_battle(runner, b, st, null) - if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16: - shop_run = {"seed": s, "runner": runner} - break - check(not shop_run.is_empty(), "商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1))) - if shop_run.is_empty(): - return - var runner = shop_run["runner"] - check(String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8, - "L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)") - check(not runner.awaiting_choice() and runner.next_candidate_ids().is_empty(), - "到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)") - # 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数 - var prods: Array = runner.shop_products() - check(prods.size() == 3, "三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)") - var by_id := _shop_products_by_id(prods) - check(int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6, - "L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)") - # 购买 atk_up:钱包 −6 · 加算区 atk +1 · 二购现价 8 - var gp0: int = runner.gp - check(runner.buy_product("atk_up"), "购买 atk_up 受理(现价 6)") - check(runner.gp == gp0 - 6, "钱包扣现价 6(%d → %d)" % [gp0, runner.gp]) - check(int(runner.run_ledger.bonus.get("atk", -1)) == 1 and int(runner.run_ledger.bonus.get("max_hp", -1)) == 0 and int(runner.run_ledger.bonus.get("armor_class", -1)) == 0, # 加算区住 RunLedger - "统一冒险加算区 atk +1(bonus={atk,max_hp,armor_class} 账本)") - by_id = _shop_products_by_id(runner.shop_products()) - check(int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1, - "atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)") - # 行进段百分比归零(购买/生效断言按加算区裸值口径——调试浮窗百分比后乘;hp 快照钳到新口径上限防 spawn 越界,测试通道) - runner.set_hero_override(0, 0) - runner.hero_hp = mini(runner.hero_hp, runner.effective_max_hp()) - # hp_up:上限与当前生命同步 +3(封顶新上限) - var hp_before: int = runner.hero_hp - var mhp_before: int = runner.effective_max_hp() - check(runner.buy_product("hp_up"), "购买 hp_up 受理(现价 4)") - check(runner.gp == gp0 - 6 - 4, "钱包扣 4(%d)" % runner.gp) - check(runner.effective_max_hp() == mhp_before + 3, "生命上限 +3(%d → %d——加算区 max_hp 轴)" % [mhp_before, runner.effective_max_hp()]) - check(runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp()), "当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp]) - # armor_up:AC 10+1 - check(runner.buy_product("armor_up"), "购买 armor_up 受理(现价 6)") - check(runner.effective_ac() == int(_db.hero.armor_class) + 1, "AC = 10+1(armor_class 加算区轴,无百分比通道)") - # 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝 - var guard_drain := 20 - var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环 - while guard_drain > 0: - guard_drain -= 1 - var hp_price: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"]) - if runner.gp < hp_price: - break - if not runner.buy_product("hp_up"): - break - hp_buys += 1 - var gp_dry: int = runner.gp - var tier_dry: int = runner.purchased_tier("hp_up") - var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"]) - check(not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry, - "余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry]) - # 离开 → 回选路口(候选 [9]) - check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9], - "离开商店 → 回选路口(候选 [9]——零自动推进收口)") - # 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案) - check(runner.effective_atk() == int(_db.hero.atk) + 1, "主角攻击生效值 = 2+1(基础表值 + 加算区)") - check(runner.pick_next_node(9) and runner.current_battle() != null, "选 Boss(节点 9)开战") - var snap: Dictionary = runner.current_battle().presentation_snapshot() - # max_hp 期望按实际购买笔数推导(可购笔数 - # 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不锚死恰好 1 笔) - check(int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys, - "战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys]) - # 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零 - var fin_end := {} - runner.level_finished.connect(func(p): fin_end["p"] = p) - var g3 := 60 - while not fin_end.has("p") and g3 > 0: - g3 -= 1 - var b_end = runner.current_battle() - if b_end == null: - break - autoplay._autoplay_battle(runner, b_end, _battle_st("normal"), null) - check(fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"], - "冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", ""))) - check(runner.gp == 0, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)") - - -func _shop_products_by_id(prods: Array) -> Dictionary: - var out := {} - for p in prods: - out[String(p["product_id"])] = p - return out - - -# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- - -func _smoke_shop_graph() -> void: - print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---") - for L in [2, 3]: - var row := {} - for r in _db.level.rows: - if int(r.level_index) == L: - row = r - var shops: Array = [] - var boss_id := -1 - var max_id := -1 - for n in _level_active_nodes(L): - var t := String(n.get("type", "")) - if t == "shop": - shops.append(int(n.get("node_depth", -1))) - if t == "boss": - boss_id = int(n.get("node_depth", -1)) - max_id = maxi(max_id, int(n.get("node_depth", -1))) - check(shops.size() == 1 and int(shops[0]) == 8, "L%d 商店房恰 1 且 node_depth=8(next=[9])" % L) - check(boss_id == 9 and boss_id == max_id, "L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L) - var paths: Array = _all_entry_boss_paths(_level_active_nodes(L)) - var via_shop := true - for p in paths: - if not p.has(8): - via_shop = false - check(paths.size() >= 2 and via_shop, "L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()]) - # 跨表校验:(·,boss,9) Boss 行精确存在 · (·,battle,8) 无战斗行残留(陈旧行清理确认) - var bg_keys := {} - for row in _db.board_gen.rows: - bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true - var en_keys := {} - for row in _db.enemy.rows: - en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true - for L in [2, 3]: - check(bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L), "(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" % L) - check(not bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L), "(%d,battle,8) 商店房无板/战斗行集残留(陈旧行已清理)" % L) - check(bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8"), - "L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)") - - -# ---- ⑤ 商店共享池(三选一同构/乘法价/跨渠道共账/切边/断点扩键)---- - -func _smoke_shop_pool() -> void: - print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---") - var autoplay = load("res://smoke/level_autoplay.gd") - var shelf_kinds: Array = [] - for row in _db.shop_pool_rows(): - shelf_kinds.append(String(row.get("kind_id", ""))) - # ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架) - var scen := {} - var first_shelf_a: Array = [] - var first_shelf_b: Array = [] - for s in range(42, 92): - var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s)) - a8.set_hero_override(900, 400) - if not a8.start_adventure(s, {"first_script": true, "shop_mode": "pool"}): - continue - if not _adv_drive_to_shop(a8, _adv_st(), autoplay): - continue - scen = {"a": a8, "s": s, "r": a8.runner()} - first_shelf_a = (a8.runner().shop_shelf() as Array).duplicate(true) - break - check(not scen.is_empty(), "池模式商店房场景可达(seed=%s)" % str(scen.get("s", -1))) - if not scen.is_empty(): - # 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面) - var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"]))) - a8b.set_hero_override(900, 400) - if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \ - and _adv_drive_to_shop(a8b, _adv_st(), autoplay): - first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true) - check(first_shelf_a.size() == 3, "进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size()) - # MC-1 商店独立刷新(P-C2+Q2 裁——基础免费 0;GP 刷新 ceil(20×1.8^n) provisional;重抽同源) - if not scen.is_empty(): - var r8 = scen["r"] - var gp0: int = int(r8.gp) - var cost0: int = r8.shop_refresh_cost() - check(cost0 == int(_db.adventure.shop_refresh_cost_base), "刷新首价=基价(%d)" % cost0) - check(r8.shop_refresh_free_left() == 0, "基础免费 0 次(Q2——天赋增益位后续添加)") - var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true) - check(r8.shop_refresh(), "GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0]) - check(int(r8.gp) == gp0 - cost0, "GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)]) - check(int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve))), "刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost())) - var same_shelf := first_shelf_a.size() == first_shelf_b.size() - if same_shelf: - for i in first_shelf_a.size(): - if String(first_shelf_a[i].get("kind_id", "")) != String(first_shelf_b[i].get("kind_id", "")) \ - or int(first_shelf_a[i].get("price", -1)) != int(first_shelf_b[i].get("price", -1)): - same_shelf = false - break - check(same_shelf, "同种子同货架逐位(确定性重放承载——断点货架续玩语义)") - if scen.is_empty(): - return - var a8 = scen["a"] - var r8 = scen["r"] - # ② 货架投影形制:kind 词表封闭 + 现价公式推导(ceil(base×b^(关序−1)×A^0)——核心现算,呈现零复算) - var l2 := int(r8.level_row.level_index) - for c in r8.shop_shelf(): - # T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉 - # (数值卡走 shop_pool_row 推导 / relic 卡走 15 固定锚——同公式不同基价) - var ckind := String(c.get("kind_id", "")) - var is_relic := ckind == "relic" - check(is_relic or shelf_kinds.has(ckind), "货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind) - var expect_p: int - if is_relic: - expect_p = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) - else: - var prow: Dictionary = _db.shop_pool_row(ckind) - expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) - check(int(c.get("price", -1)) == expect_p, - "首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p]) - # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体) - var buy_c: Dictionary = {} - for c in r8.shop_shelf(): - if int(c.get("price", 1 << 30)) <= int(r8.gp): - buy_c = c - break - check(not buy_c.is_empty(), "可购卡在架(GP %d——种子扫描保证首购空间)" % int(r8.gp)) - if not buy_c.is_empty(): - var kind := String(buy_c.get("kind_id", "")) - var tier := String(buy_c.get("tier", "")) - var val_map := {"attack": "atk", "max_hp": "max_hp", "armor": "armor_class", "ap": "ap"} - var gp0: int = int(r8.gp) - var taken0: int = int(a8._curio_taken.get(kind, 0)) - var val0: int = int(a8.run_ledger.bonus.get(String(val_map[kind]), -999)) - check(r8.buy_pool_item(kind), "购买受理(pool 口——%s|%s)" % [kind, tier]) - check(int(r8.gp) == gp0 - int(buy_c.get("price", 0)), "钱包扣现价(%d → %d)" % [gp0, int(r8.gp)]) - check(int(a8.run_ledger.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.curio_value(kind, tier)), - "加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))]) - check(int(a8._curio_taken.get(kind, 0)) == taken0 + 1, "共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._curio_taken.get(kind, 0))]) - # A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式) - var second: Dictionary = {} - for c in r8.shop_shelf(): - if int(c.get("price", 1 << 30)) <= int(r8.gp): - second = c - break - if not second.is_empty(): - var prow2: Dictionary = _db.shop_pool_row(String(second.get("kind_id", ""))) - var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * _db.shop_price_a())) - check(int(second.get("price", -1)) == expect_p2, - "A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2) - check(r8.buy_pool_item(String(second.get("kind_id", ""))), "同 visit 第二购受理(A 链软闸在价不在限)") - # ④ 切边零入店(白-box 重抽 40 次——GP 袋/规则改写型特性永不在架;重抽消费 shop_draw 签,场景尾不回写主时间线) - var cut_seen := false - for i in 40: - r8._shop_draw_shelf() - for c in r8.shop_shelf(): - if String(c.get("kind_id", "")) == "gp" or String(c.get("kind_id", "")) == "treasure_item": - cut_seen = true - check(not cut_seen, "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)") - # ⑤ 池空硬闸(库存尽剔出牌库——TP0:white-box 耗尽 attack 令牌后货架零 attack + 消费口拒绝) - for di8 in range(a8._curio_deck.size() - 1, -1, -1): - if String((a8._curio_deck[di8] as Dictionary).get("kind", "")) == "attack": - a8._curio_deck.remove_at(di8) - var attack_seen := false - for i in 40: - r8._shop_draw_shelf() - if (r8.shop_shelf() as Array).any(func(c): return String(c.get("kind_id", "")) == "attack"): - attack_seen = true - break - check(not attack_seen, "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)") - var stock_before: int = a8.pool_stock("attack") - check(not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before, - "池空消费拒绝(handler 零副作用——买光即枯竭硬闸)") - # ⑥ 跨渠道共库 shop→reward 向:回填一枚 attack 令牌 → 消费口受理 → 奖励牌库同步摘牌 - a8._curio_deck.append({"kind": "attack", "tier": "common"}) - check(a8._consume_pool_purchase("attack", "common"), "商店消费口受理(_apply_curio_pick 同口——真单点库)") - var reward_attack_seen := false - for t6 in a8._draw_curio_candidates(): - if String((t6 as Dictionary).get("kind", "")) == "attack": - reward_attack_seen = true - break - check(not reward_attack_seen, - "商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)") - # ⑦ 断点扩键(P2 甲——save v4 域内:shop_shelf/shop_visit_buys 入快照;离开店=空架合法态) - var sm = load("res://core/save_manager.gd").new("user://_smoke_shop_pool_save.json") - sm.load_from_disk() - a8.set_save_manager(sm) - check(r8.continue_after_node() and r8.awaiting_choice(), "离开商店 → 回选路口(货架态清零落档——零自动推进收口)") - var bp: Dictionary = sm.breakpoint_snapshot() - check(bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty(), - "断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)") - check(bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0, - "断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)") - sm.clear_breakpoint() - # ⑧ 终局池读数(run_adventure pool 面——商店与奖励跨渠道消耗后的终局余量出讫) - var st_pool: Dictionary = autoplay.run_adventure(_db, 42, 0, 0, false, null, "normal", "min", {}, {}, {"mode": "pool"}) - var ps: Dictionary = st_pool.get("pool_stock", {}) - check(ps.size() == 6, "终局池读数六键出讫(run_adventure pool 面——四池种+T5 专长凭证+T6 heal 行,读数只锚一次载体)") - var ps_ok := true - for k in ps.keys(): - var quota := 0 - for tier in _db.adventure.TIERS: - quota += int(_db.adventure.curio_quota(String(k), tier)) - if int(ps[k]) < 0 or int(ps[k]) > quota: - ps_ok = false - check(ps_ok, "终局池余量在域 [0,配额](%s)" % str(ps)) - - -# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限) -func _all_entry_boss_paths(nodes: Array) -> Array: - var by_id := {} - var inbound := {} - for n in nodes: - by_id[int(n.get("node_depth", -1))] = n - for n in nodes: - for tid in n.get("next", []): - inbound[int(tid)] = true - var entry := -1 - for n in nodes: - if not inbound.has(int(n.get("node_depth", -1))): - entry = int(n.get("node_depth", -1)) - var paths: Array = [] - var stack: Array = [[entry]] - while not stack.is_empty(): - var path: Array = stack.pop_back() - var cur: Dictionary = by_id[path[path.size() - 1]] - var outs: Array = cur.get("next", []) - if outs.is_empty(): - paths.append(path) - continue - for t in outs: - var np := path.duplicate() - np.append(int(t)) - stack.append(np) - return paths - - -# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)---- - -func _smoke_nine_types_gp_stream() -> void: - print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---") - check(RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item"), - "格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)") - 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) - check(cell_rows == 18, "奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)") - # 危险格负向直伤当量(负向≈正向 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): - check(int(row.get("power", -1)) == int(mine_power[rid]), - "危险格负向直伤 %s power=%d(卡⑧投值)" % [rid, 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 - check(pos_all_in_pool and neg_all_zero, - "正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight) - check(gp_pos == 2 and gp_neg == 0, "gp 型两行(GP 袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)") - var rng = _fresh_rng(42) - check(rng.counter() == 0, "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)") - - -# ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; -# conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- -func _smoke_meta() -> void: - print("--- 元进度·MetaTable 表结构 ---") - var meta_tbl = _db.meta - check(meta_tbl != null and meta_tbl.validate().is_empty(), - "MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)") - check(meta_tbl.boss_keystone.size() == 3 - and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2, - "MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)") - 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() - check(attrs_ok, "conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)") - 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)) - check(boss_ok, "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)") - _meta_pricing_and_buy() - _meta_settle_and_workshop() - _meta_injection() - _smoke_forge_axes() - _status_panel_projection() - _career_overlay_projection() - - -# ---- 锻造工坊·两根类型轴所有权移交(V5 前半:hero.tres 两轴删除+锻造配置默认值)---- -# 后半(build_adventure_params 注入默认值/选中覆盖)随 T4 注入接线同批断言;写口/解锁面归 T3。 -func _smoke_forge_axes() -> void: - print("--- 锻造工坊·类型轴所有权移交(T2)---") - # V5:hero.tres 静态表两轴已删(全表加载零违例——config_db_test 同款闸;此处钉字段不在位) - check(not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero), - "hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕") - # 锻造配置默认值 = normal/light(常驻持有——与柜台解锁状态解耦,未点亮也照常可读) - check(_db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light", - "锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕") - # 单位快照回退面:spawn 三参形态(零注入)= 锻造配置默认值 - var uc = load("res://core/unit_container.gd").new(_db) - check(uc.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理(三参形态——两轴缺省回退面)") - check(String(uc.hero_unit().get("attack_type", "")) == "normal" - and String(uc.hero_unit().get("armor_type", "")) == "light", - "主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕") - - # ---- 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) - check(mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 - and sv2.keystone_balance() == 80, - "长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕") - var sv2r = _meta_new_save(_META_SMOKE_PATHS[13]) - check(sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80, - "购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕") - # V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲) - check(mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 - and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0, - "长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕") - # 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() - check(not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 - and sv2d.forge_routes_snapshot() == fr_dry, - "余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕") - # V2④:已满级拒购(cap 闸);未解锁拒购 - sv2.set_forge_route_level("sword", 5) - check(not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear"), - "已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕") - # V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁 - check(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"), - "通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕") - check(mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 - and sv2.keystone_balance() == 30, - "派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕") - # 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) - check(not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward"), - "通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕") - # V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝) - sv3.set_forge_route_level("light", 5) - check(mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300, - "直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕") - # V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购) - sv2d.add_keystone(500) - check(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"), - "不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕") - # V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价) - var ks4: int = sv3.keystone_balance() - check(mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 - and sv3.forge_route_level("ward") == 1, - "两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕") - # V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效) - var ks6: int = sv3.keystone_balance() - check(mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" - and sv3.keystone_balance() == ks6, - "选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕") - check(sv3.forge_route_level("light") == 5, - "切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕") - # V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁) - check(not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword", - "未解锁路线拒选零副作用〔锻造工坊批 V6〕") - # V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值) - check(mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" - and mp2d.hero_armor_type() == "light", - "类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕") - - # ---- 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) - check(String(bp_params.get("attack_type", "")) == "normal" - and String(bp_params.get("armor_type", "")) == "light", - "build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕") - sv4.add_keystone(1000) - check(mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear"), - "长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)") - var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4) - check(String(bp_sel.get("attack_type", "")) == "pierce" - and String(bp_sel.get("armor_type", "")) == "light", - "build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕") - # V7:hero_override 公式对拍=树 flat+锻造路线加值(旧路线练度待命不生效;切回即生效) - check(int(mp4.hero_override().get("atk", -1)) == 1 - and int(mp4.hero_override().get("max_hp", -1)) == 0 - and int(mp4.hero_override().get("armor_class", -1)) == 0 - and not mp4.hero_override().has("hit"), - "hero_override=锻造路线加值(选中长枪 Lv1→atk +1;甲 0 级→AC +0;树零分量;无 hit 键)〔锻造工坊批 V7〕") - sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) - check(int(mp4.hero_override().get("atk", -1)) == 1, - "换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕") - check(mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3, - "切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕") - # 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) - check(mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy"), - "重甲直购解锁+首购+选中(三层 AC 夹具)") - 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) - check(adr5.start_adventure(77, p5), "注入参数冒险启动(两轴+override 同面)") - var r5 = adr5.runner() - check(int(r5.effective_ac()) == 12 + 1, - "入战 AC 三层:甲型基础 12(重甲)+路线加值 1+局内 0〔锻造工坊批 V7〕") - check(adr5.hero_attack_type == "normal" and adr5.hero_armor_type == "heavy", - "参数面快照入 runner(attack_type=normal/armor_type=heavy)〔锻造工坊批 V6〕") - # V6 锁死:开冒险后改存档选中→参数面/注入值不变(快照冻结;light=初始持有已解锁可切) - check(mp5.forge_select_armor("light") and adr5.hero_armor_type == "heavy" - and int(r5.effective_ac()) == 12 + 1, - "冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕") - # 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 - check(pierce_rows >= 1, - "enc 两轴直达 spawn 英雄快照(hero_attack 行携带 pierce 键——注入链末端)〔锻造工坊批 V5〕") - # spawn 显式注入面(三参旧形态零注入已在上方覆盖——此处钉显式值) - var uc2 = load("res://core/unit_container.gd").new(_db) - check(uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy") - and String(uc2.hero_unit().get("attack_type", "")) == "magic" - and String(uc2.hero_unit().get("armor_type", "")) == "heavy", - "spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕") - # 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 - check(not tree_atk_ac, - "树零攻防来源复核(12 行 effects 键集无 atk/armor_class——契约 1)〔锻造工坊批 V10〕") - - -# ---- 状态面板只读投影一致性(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) - check(po.visible, "状态面板开层可见(open_status 直开——P4 甲零动效路径)") - check(String(po._balance_badge.value_label.text) == "钥石 %d" % mp.keystone_balance(), - "余额徽投影 = keystone_balance() 读数(%s)" % po._balance_badge.value_label.text) - 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 - check(ws_ok, "秘宝匣 12 行投影 = treasure_shop_status() 逐项(名称/钥石价与条件进度/解锁态——首件解锁夹具在列)〔秘宝批 V19 呈现数据源〕") - 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) - 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) - check(co.visible, "生涯页开层可见(open_career 直开——零动效路径)") - check(String(co._total_label.text) == "2 局", - "总局数投影 = adventure_total() 读数(%s)" % co._total_label.text) - check(String(co._cleared_label.text) == "1 局(三关全通)", - "通关局数投影 = adventure_cleared() 读数(%s)" % co._cleared_label.text) - 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))]) - check(String(co._clears_label.text) == " · ".join(expect_clears), - "各关通关次数投影 = clear_count() 逐关读数(%s)" % co._clears_label.text) - check(String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次", - "Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" % [co._boss_first_label.text, co._boss_kills_label.text]) - check(String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance(), - "钥石余额徽投影 = keystone_balance() 读数(%s)" % co._keystone_badge.value_label.text) - 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() - check(v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0, - "v5 现役档升 v6 载入成功(计数域缺省补零)") - 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) - check(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", - "旧档载入后生涯页正常显示(v5 升 v6:总局数/通关局数显示 0,既有钥石余额原样呈现)") - co_v5.visible = false - co_v5.free() - - -# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 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 - - -# ---- 元进度 ②树购买面(购买受理/持久/余额不足拒绝/未知节点拒绝; -# 行价=表值 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)) - check(mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0, - "树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0]) - var sv_r = _meta_new_save(_META_SMOKE_PATHS[0]) - check(sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0, "购买持久(reload 后级与余额不变)") - # ③ 余额不足 / 未知节点: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() - check(not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 - and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before, - "余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)") - check(not mp_dry.tree_buy("no_such_node"), "未知节点 false 零副作用(词表外出域)") - - -# ---- 元进度 ④注入可见 + 白板成立(验收 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) - check(int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0)), - "生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)") - check(int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0, - "攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)") - check(not mp.hero_override().has("hit"), - "命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)") - var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) - check(adr.start_adventure(920251, {"hero_override": mp.hero_override()}), "注入参数冒险启动(start_adventure params 通道)") - var r = adr.runner() - var ov: Dictionary = mp.hero_override() - check(int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)), - "首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)") - check(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)), - "首战 hero_max_hp 与开局生命账本 = 表值 + 注入档") - # 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红) - var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) - check(adr_w.start_adventure(920251), "白板冒险启动(无 params 实参——B1 空参数路径)") - var sv_white = _meta_new_save(_META_SMOKE_PATHS[5]) - var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) - check(adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}), - "零强化参数冒险启动(新档 hero_override 全零)") - var rw = adr_w.runner() - var rz = adr_z.runner() - check(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()), - "白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)") - check(int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class), - "ac 轴:白板恒表值、未换强化时注入零位移") - _meta_smoke_cleanup() - - -# ---- 元进度 ⑥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] - check(bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1)) - and int(drop1.get("level", 0)) == 1, "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)") - check(int(d1.get("total", 0)) == int(row1.get("first_kill", 0)), "明细 total = keystone_total(逐杀掉落全源——无二次派生加账)") - 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] - check(not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)), - "再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)") - 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}) - check(not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)), - "重复杀第三轮恒定不递减(验收5——无衰减曲线)") - # 混合归因:通关两关 → 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}) - check(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)), - "两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)") - # ⑦ 败北零通关摘要:余额只增不减(零入账)、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}) - check(dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before, - "败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)") - # ⑧ 秘宝解锁服务(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) - check(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"), - "条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕") - check(not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10, - "余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕") - check(mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") - and sv_u.keystone_balance() == 0, - "钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕") - check(not mp_u.buy_treasure_unlock("morning_order"), - "已解锁重复购买拒绝〔秘宝批 V3〕") - check(mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick"), - "条件未达不解锁(远程镐=通关 1 次)〔秘宝批 V4〕") - sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径) - check((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(), - "远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:通关=打通整局冒险)〔秘宝批 V4〕") - sv_u._data["meta"]["boss_kill_count"] = 3 - check((mp_u.refresh_treasure_unlocks() as Array).has("refresh_license"), - "刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕") - sv_u._data["meta"]["adventure_cleared"] = 3 - check(not sv_u.item_unlocked("sky_eye"), "天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕") - sv_u._data["meta"]["boss_kill_count"] = 4 - check((mp_u.refresh_treasure_unlocks() as Array).has("sky_eye"), - "天眼双条件 AND(通关≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕") - sv_u._data["meta"]["vision_used"] = 10 - check((mp_u.refresh_treasure_unlocks() as Array).has("beacon_network"), - "信标网络=真视类工具累计使用≥10(行为计数新域——use_treasure 受理成功累计)〔秘宝批 V4〕") - 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 - check(r_n == 5, "解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕") - # ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10 - var rs: Array = sv.recent_summaries() - check(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, "摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)") - 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}) - check(sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11, - "摘要 cap 10 且最新在前(12 连 settle 截断后 seed 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) - check(Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0, - "白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)") - check(mp_a.toggle_attunement("morning_order") == false, - "满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕") - sv_a.add_keystone(200) - sv_a.unlock_item("morning_order") - sv_a.unlock_item("twin_loader") - check(mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 - and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1, - "Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)") - mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, - "stopped_at": 2, "keystone_total": 0, "master_seed": 21}) - check(Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1, - "通关 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕") - sv_a.set_tree_level("att_socket1", 1) - check(Attunement.slots(_db, sv_a) == 1, - "树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)") - check(not mp_a.toggle_attunement("sky_eye"), - "稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕") - check(mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("twin_loader") == false, - "装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)〔秘宝批 V5 换主语〕") - 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) - check((mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order"), - "装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕") - # ⑫ 充能重置 / 刷新双计数器 - var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12)) - check(a12.start_adventure(12, {"attunement": ["incense_stick", "sky_eye"]}), - "充能重置场景:装配冒险启动(seed 12)〔秘宝批 V8 换主语〕") - check(int(a12.run_ledger.attune_charges.get("incense_stick", -1)) == 1 and int(a12.run_ledger.attune_charges.get("sky_eye", -1)) == 1, # 充能账住 RunLedger - "充能账本按装备 active_charged 件重置(新目录表值:线香 battle 级 1 次/天眼 level 级 1 次——周期重置点归 V8 逐形态面)") - 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": {}} - check(a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2, - "刷新双计数器——X 优先消耗(x1y2 → x0y2)") - check(a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0 - and a12.refresh_curio() == false, - "刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)") - a12._reset_refresh_x() - check(a12._attune_refresh_x == 0 and a12.attune_refresh_left() == 0, - "刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕") - # ⑬ 主菜单开局传参面(开新/续玩两入口同调三键随开局下传防回归:真实入口曾 - # 三键恒空(缺陷①),冒烟夹具直呼 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() - check(rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2, - "开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 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"}) - check((p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array), - "主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)") - check(int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges(), - "主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)") - check((p_new.get("tree_rules", []) as Array) == rules_e, - "主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)") - check(bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool", - "主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入") - var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, - {"treasure_unlocked": {}}) - 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": []} - check(adr_res.resume_adventure(bp_min, p_resume), - "继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)") - check((adr_res._tree_rules as Array) == rules_e, - "继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)") - _meta_smoke_cleanup() - - -# ---- 天赋树冒烟(v0.8 12 节点:schema/购买面/Fork 一节点双态/彩蛋特化)---- - -func _smoke_tree() -> void: - print("--- 天赋树(12 节点 · 购买面 · Fork 双态 · 彩蛋觉醒)---") - # 〔V1〕结构:12 行 + schema 零违例 - check(_db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12, - "meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕") - check(_db.meta_tree.validate().is_empty(), - "MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)") - # 彩蛋行硬要求(契约 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 - check(ley_ok, "easter_ley effects 键集 ⊆ {max_hp, hit}(攻/护分量已摘除——契约 9)〔天赋树批 V8 落点〕") - # 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] - check(not t2.validate().is_empty(), "schema 反例:vein 出域被拒") - 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] - check(not t2.validate().is_empty(), "schema 反例:fork_group 出域(fg_mastery 已退役组)被拒") - 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] - check(not t2.validate().is_empty(), "schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕") - 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] - check(not t2.validate().is_empty(), "schema 反例:requires 悬空被拒") - # 购买面(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) - check(mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1, "树购买受理(脊柱生命 L1——树全钥石单币)") - check(not mp.tree_can_buy("sp_hp"), - "行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕") - check(mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1"), - "命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)") - check(int(_db.tree_node("forge_workshop").get("price", -1)) == 0, "锻造工坊钥匙零币(free)") - check(mp.tree_can_buy("sp_hp"), "行门槛恰达开放(总等级 4 ≥ 4×1)") - check(mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2, "第 2 级购买受理(门槛恰达当刻开放)") - # Fork 一节点双态(V9) - sv.add_keystone(100) - check(mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a", - "Fork 购买=解锁+默认 A 态(一节点双态——单行承载)") - check(not mp.tree_can_buy("fork_posture"), "已解锁 Fork 重复购买拒绝(cap 1)") - check(not mp.tree_toggle_fork("fg_posture", "a"), "同态重复切换零操作(false)") - check(not mp.tree_toggle_fork("fg_sense", "b"), "未解锁 Fork 切换拒绝(解锁唯一入口=购买面)") - var bal_pre_toggle: int = sv.keystone_balance() - check(mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b", - "零币切态 b(购买解锁后 A/B 可切)") - check(int(sv.keystone_balance()) == bal_pre_toggle, "切态零消费(钥石余额差分不动)") - check((mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike"), - "规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)") - check(mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike"), - "切回 A 态键集随换(六键词表在册——V11 校验面归单元段)") - # 彩蛋特化觉醒(V8) - check(not mp.tree_can_buy("easter_ley"), "彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)") - 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 - check(sv.tree_level("easter_ley") == 1, "两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)") - var bal_now: int = sv.keystone_balance() - mp.tree_buy("sp_hp") - check(int(sv.keystone_balance()) == bal_now, "觉醒零消费(钥石余额不动)") - _meta_smoke_cleanup() - - -# ---- 天赋树六键行为冒烟(V10:开局姿态窗/危险直觉限次/财富之道聚散/守侧失误补偿; -# 引擎直构 + 编排授予段直注(_grant_seg._judge_seal=结算授予段封印判定单口))---- -func _smoke_counter_buckets() -> void: - print("--- 效果引擎无状态化四桶正向验收(卡② §9 V1/V6)---") - # V1——adventure 桶跨战持久:同一 RunLedger 下两战各真实 request_dig 一次, - # 断付费计数经 ctx 就地写入宿主字典并跨战累积(副本搬运态:读不到上一战值→第 2 战仍 1,此断言会红)。 - var led = load("res://core/run_ledger.gd").new() - var RigS = load("res://core/battle_orchestrator.gd") - var RngS = load("res://core/rng_service.gd") - var enc1 = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": ["twin_pick"], "tree_rules": [], "battle_index": 1, - "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, - "node_type": "battle", "ledger": led} - var o1 = RigS.new(_db, _fresh_rng(779301)) - o1.begin_battle(enc1) - var dig1: bool = o1.action_economy.request_dig(0, false) - check(dig1, "V1 第1战真实 request_dig 受理(begin_battle 后 ACTIVE 相位)〔无状态化 V1〕") - check(int(led.adventure_counters.get("adv_twin_digs", 0)) == 1, - "V1 第1战付费挖格经 ctx 就地写 adventure 桶=1(无 export 搬运时点)〔无状态化 V1〕") - var enc2 = enc1.duplicate(true) - enc2["battle_index"] = 2 - var o2 = RigS.new(_db, _fresh_rng(779302)) - o2.begin_battle(enc2) - check(EffectEngine.counter(o2.counters_ctx, "adv_twin_digs") == 1, - "V1 第2战 begin_battle 经 ctx 读到上一战持久值=1(读路径走宿主字典引用,非副本种子回灌)〔无状态化 V1〕") - o2.action_economy.request_dig(0, false) - check(int(led.adventure_counters.get("adv_twin_digs", 0)) == 2, - "V1 第2战挖后 adventure 桶跨战累积=2(run_ledger:42 判空早退缺陷消解的回归钉面——旧副本态恒 1)〔无状态化 V1〕") - # V6——battle 桶随宿主销毁:第1战标错落 insurance_used(battle 桶无前缀),次战新编排器读 0。 - var encA = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": ["perfect_insurance"], "tree_rules": [], "battle_index": 1, - "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, - "node_type": "battle", "ledger": load("res://core/run_ledger.gd").new()} - var oA = RigS.new(_db, _fresh_rng(779401)) - oA.begin_battle(encA) - var safe_a: Variant = null - for ca in oA.minesweeper.current_board().all_cells(): - if not oA.minesweeper.current_board().is_mine(ca): - safe_a = ca - break - oA._grant_seg._judge_seal({"cell": safe_a, "hit": false}, 1) - check(EffectEngine.counter(oA.counters_ctx, "insurance_used") == 1, - "V6 第1战标错落 battle 桶 insurance_used=1(_apply_counter_hit 静态写口经 ctx)〔无状态化 V6〕") - var encB = encA.duplicate(true) - encB["battle_index"] = 2 - var oB = RigS.new(_db, _fresh_rng(779402)) - oB.begin_battle(encB) - check(EffectEngine.counter(oB.counters_ctx, "insurance_used") == 0, - "V6 battle 桶随宿主销毁:次战新编排器 fx_counters_battle 天然空,读 insurance_used=0〔无状态化 V6〕") - - -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() - check((fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1, - "先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕") - 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(), - "先手压制:首轮窗外地不触发(第二轮/第二关首战首轮——「每战首轮」旧语义换代件)") - check((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(), - "稳健拓荒:首轮窗内受击出盾、窗外不出(shield damage_taken 同窗条件)") - # 危险直觉·锋:限次闸(引擎直构——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 - check(got_mult and got_add, "锋:踩雷减半命中+限次记账(counter 0<2 闸开——damage_mult+counter_add 双行同预评估态)") - EffectEngine.counter_add(ctx_fz, "lvl_tree_sense_sharp_used", 2) # 直调置数(V10 注入口径——置 level 桶计数后走事件路径) - check((fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array).is_empty(), "锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)〔无状态化 V5〕") - 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,同字典即持久) - check((fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty(), - "锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕") - # 守侧五场景(契约 12):编排 rig——begin_battle 携 sense_guard,授予段 _grant_seg._judge_seal 直注 - var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777001)) - check(orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, - "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, - "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}), - "守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)") - 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 - check(safe.x >= 0, "守侧 rig:安全格定位(非危险格=标错负样本)") - check(int(orch.action_economy.pending_ap_delta()) == 0, "守侧 rig:首轮后 queue 干净(开局 posture queue 已兑现)") - # 场景①:当回合新封印、结算判标错且封印仍在场 → queue +1(本轮 AP 零变化=queue 承载、下轮 notify_round_start 兑现) - orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 1) - check(int(orch.action_economy.pending_ap_delta()) == 1, - "守①:当回合标错且封印在场 → 下轮 AP+1(queue 排队——本轮 AP 逐位零变化)") - # 场景③④:之前回合标错重判(封印在场状态)与同格重复——去重闸挡回,最多返还一次 - orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 2) - orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 3) - check(int(orch.action_economy.pending_ap_delta()) == 1, - "守③④:旧封印重判零触发+同格最多一次(_seal_miss_seen 去重闸=「当回合标错动作」判别器)") - # 场景②负样本:封印命中(hit=true 正向兑付路径)零触发 - orch._grant_seg._judge_seal({"cell": safe, "hit": true}, 4) - check(int(orch.action_economy.pending_ap_delta()) == 1, "守②负样本:标中/被挖封印零触发(标错事件只在 miss 分支发射——发射面过滤承载)") - # 场景⑤:末轮判出→无下一轮=queue 不兑现(编排器每战新建——队列实例随战亡,合法作废态) - var orch2 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777002)) - check(orch2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, - "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, - "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}), - "守⑤ rig:次战编排启动(新战新队列实例)") - check(int(orch2.action_economy.pending_ap_delta()) == 0, - "守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)") - # 树限次按关清零改由 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([]) - check(int(lr._spend_discounted(100)) == 100, "散未激活:商品标价原价(无 economy_spend 键)") - lr.set_tree_rules(["economy_spend"]) - check(int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1, - "散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)") - # 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×) - var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) - check(orch3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": [], "battle_index": 1, - "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, - "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}), - "聚 rig:基线编排启动(无增益侧)") - 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)) - check(orch4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": ["economy_hoard"], "battle_index": 1, - "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, - "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}), - "聚 rig:增益编排启动(tree_rules=economy_hoard)") - orch4.encounter["tree_rules"] = ["economy_hoard"] - var gp_hoard: int = orch4._roll_gp_drop() - check(gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base, - "聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)") - _meta_smoke_cleanup() - - -# ---- 战斗日志视图·行格式化呈现层同步闸(真人试玩卡断 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() - check(enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12"), "敌方伤害行格式化(baseline 行不读退役 attach)") - 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() - check(hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■"), "主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)") - 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() - check(agg_txt.contains("⚠") and agg_txt.contains("×2"), "未生效行聚合计数 ×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]) - check(view.parsed_text().length() > before, "行型 %s 格式化落地(解析文本增长)" % String(row_type)) - - -# ---- 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() - check(th != null, "V1 项目主题挂载(project.godot gui/theme/custom → main_theme.tres)") - 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: - check(type_set.has(t), "V1 主题类型覆盖 %s" % t) - # V1 红线:Button 类型词条零接触(五态矩阵归 06 第一层第二条) - check(not type_set.has("Button"), "V1 红线 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] - check(focus_hits == "", "V1 红线 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + focus_hits + ")")) - # V1:关键词条抽查在位(实例走主题链解析) - var vsb := VScrollBar.new() - check(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"), - "V1 滚动条槽/柄三态词条在位") - check(not th.has_stylebox("scroll_focus", "VScrollBar") and not th.has_stylebox("scroll_focus", "HScrollBar"), - "V1 滚动条 scroll_focus 未定义(红线)") - var hsl := HSlider.new() - check(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"), "V1 滑轨槽/填充/柄词条在位") - var le := LineEdit.new() - check(le.has_theme_stylebox("normal") and le.has_theme_color("font_color") - and le.has_theme_color("font_placeholder_color"), "V1 LineEdit 词条在位(含占位色 font_placeholder_color)") - var sp := SpinBox.new() - check(sp.has_theme_icon("updown"), "V1 SpinBox 箭头词条在位(updown 合并槽)") - var hsep := HSeparator.new() - var vsep := VSeparator.new() - check(hsep.has_theme_stylebox("separator") and vsep.has_theme_stylebox("separator"), "V1 分隔线词条在位") - var tpanel := PanelContainer.new() - var tlabel := Label.new() - check(tpanel.has_theme_stylebox("panel"), "V1 TooltipPanel 词条在位") - check(tlabel.has_theme_color("font_color") and tlabel.has_theme_font_size("font_size"), "V1 TooltipLabel 词条在位") - # 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 - check(fill_c.is_equal_approx(Color8(58, 46, 34)) and track_c.is_equal_approx(Color8(184, 165, 130)), - "V2 冻结值锚定(已填充段=BG_ROOT #3A2E22,槽=暖石 #B8A582)") - var ratio := _theme_contrast(fill_c, track_c) - check(ratio >= 4.5, "V2 滑轨两段对比 %.2f:1 ≥4.5:1(拖到中点一眼可辨)" % ratio) - else: - check(false, "V2 滑轨 stylebox 类型均为 StyleBoxFlat") - # V3:预盖件运行时证词(临时实例化读词条——无实役控件的在役证据) - var cbx := CheckBox.new() - check(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, - "V3 CheckBox 四态图标非空") - var cbt := CheckButton.new() - check(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, - "V3 CheckButton 四态图标非空(与 CheckBox 同套 checked/unchecked 名,4.7 源码实证)") - var pm := PopupMenu.new() - check(pm.has_theme_stylebox("panel") and pm.has_theme_stylebox("hover") - and pm.has_theme_font_size("font_size"), "V3 PopupMenu 词条存在(弹层预盖)") - var ob := OptionButton.new() - check(ob.has_theme_stylebox("normal") and ob.has_theme_stylebox("hover") - and ob.has_theme_stylebox("pressed") and ob.has_theme_color("font_color"), - "V3 OptionButton 词条存在(预盖)") - # 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 + " " - check(bad == "", "V4 退役实现零残留(%s)%s" % [path, "" if bad == "" else "违例:" + bad]) - # V6:中文字体防御——default_font 非空且 Label 实取字体非空 - check(th.default_font != null, "V6 主题 default_font 非空(挂主题顶掉 custom_font 也不丢中文字形)") - var lbl := Label.new() - lbl.text = "测试" - check(lbl.get_theme_font("font") != null, "V6 Label 实取字体非空(中文字形防御实证)") - 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: - check(b.has_theme_stylebox_override(st), "V1 普通钮 %s 样式盒非空" % 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") - check(n_sb is StyleBoxTexture and h_sb is StyleBoxTexture and d_sb is StyleBoxTexture, - "V1 普通钮 normal/hover/disabled 均为石纹九宫格贴图样式盒") - # 悬停=乙案:modulate 提亮(≠常态 WHITE——乙案判据) - var h_mod: Color = (h_sb as StyleBoxTexture).modulate_color - check(h_mod != Color.WHITE and h_mod.r > 1.0, "V1 普通钮 hover=modulate 提亮(乙案,异于常态 WHITE)") - # 禁用=甲案:本族底+α0.45 幽灵化 - var d_mod: Color = (d_sb as StyleBoxTexture).modulate_color - check(is_equal_approx(d_mod.a, 0.45) and d_mod.r == 1.0 and d_mod.g == 1.0, - "V1 普通钮 disabled=本族底 α0.45 幽灵化(甲案)") - # 错配修正判据:disabled≠pressed(旧「禁用=按压图」) - check(d_sb != p_sb, "V1 普通钮 disabled≠pressed(旧错配已修正)") - # 焦点=甲案:INK 2px 内缩 1px 环、无底(叠加态不遮钮底) - check(f_sb is StyleBoxFlat, "V1 普通钮 focus 样式盒为扁平环(StyleBoxFlat)") - var fsb := f_sb as StyleBoxFlat - check(fsb.border_color == uikit_s.INK, "V1 焦点环色=INK(与 font_focus_color 同源)") - check(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), - "V1 焦点环宽=2px") - check(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), - "V1 焦点环内缩=1px") - check(not fsb.draw_center and fsb.bg_color.a < 0.001, "V1 焦点环无底(叠加态不遮钮底)") - b.free() - # 主钮:五态非空+本族平涂(pressed/disabled 非石纹)+disabled≠pressed+hover 提亮档承现行 - var pb = uikit_s.primary_button("测试") - for st in states: - check(pb.has_theme_stylebox_override(st), "V1 主钮 %s 样式盒非空" % 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") - check(ppn is StyleBoxFlat and pdd is StyleBoxFlat, "V1 主钮 pressed/disabled=本族平涂(非石纹)") - check((ppn as StyleBoxFlat).bg_color == Color8(214, 172, 88), "V1 主钮 pressed=烛光黄深档(现行值不动)") - check(is_equal_approx((pdd as StyleBoxFlat).bg_color.a, 0.45), "V1 主钮 disabled=本族底 α0.45 幽灵化") - check(pdd != ppn, "V1 主钮 disabled≠pressed") - check((phv as StyleBoxFlat).bg_color == uikit_s.CANDLE.lightened(0.06), "V1 主钮 hover=提亮档(承现行不动)") - pb.free() - # 危险钮:补本族 pressed(深档)+disabled 幽灵化+focus;pressed/disabled 非石纹 - var db = uikit_s.danger_button("测试") - for st in states: - check(db.has_theme_stylebox_override(st), "V1 危险钮 %s 样式盒非空" % st) - var dpr: StyleBox = db.get_theme_stylebox("pressed") - var dds: StyleBox = db.get_theme_stylebox("disabled") - check(dpr is StyleBoxFlat and dds is StyleBoxFlat, "V1 危险钮 pressed/disabled=本族平涂(非石纹,旧错配已修正)") - check((dpr as StyleBoxFlat).bg_color.r < 180.0 / 255.0, "V1 危险钮 pressed=深红深档(暗于常态)") - check(is_equal_approx((dds as StyleBoxFlat).bg_color.a, 0.45), "V1 危险钮 disabled=本族底 α0.45 幽灵化") - check(dds != dpr, "V1 危险钮 disabled≠pressed") - db.free() - # V2 主题红线维持(Button 类型词条零接触+focus 族词条零定义)——与 _smoke_global_theme 同判据复述 - var th := ThemeDB.get_project_theme() - if th != null: - check(not th.get_type_list().has("Button"), "V2 红线维持 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] - check(focus_hits == "", "V2 红线维持 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + 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") - check(v != null and int(v) == 0, "V3 工程设置 default_texture_filter=0(Nearest,翻转面=立绘/瓦片/九宫格 crisp)") - var uikit_s = load("res://presentation/views/ui_kit.gd") - var vg = uikit_s.vignette_rect() - check(vg.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, "V3 白名单在位:暗角 vignette 显式 LINEAR") - vg.free() - var bm = uikit_s.beams_rect() - check(bm.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, "V3 白名单在位:光柱 beams 显式 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 - check(max_a >= 12.0 / 255.0 and max_a <= 0.05, - "T4 光柱带心峰值 α≈0.05(8bit 截断量化下限 12/255,实测 %.5f)" % max_a) - check(mid_a > 100, "T4 光柱带内连续 alpha 过渡像素 %d 个(旧零过渡硬边已修)" % mid_a) - check(top_sum > bot_sum * 2.0, "T4 光柱纵向衰减生效(上半亮度 %.0f > 下半 %.0f 的 2 倍——上亮下暗)" % [top_sum, bot_sum]) - bm.free() - var gb = uikit_s.gradient_base(Color.BLACK, Color.WHITE) - check(gb.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, "V3 白名单在位:地图渐变 gradient_base 显式 LINEAR") - gb.free() - # 既有显式 LINEAR 两处不丢(源码锚定——脚影/状态图标) - var bw_src: String = FileAccess.get_file_as_string("res://presentation/views/board_world.gd") - check(bw_src.contains("shadow.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR"), - "V3 脚影显式 LINEAR 不丢(board_world.gd)") - var ss_src: String = FileAccess.get_file_as_string("res://presentation/views/status_slot_row.gd") - check(ss_src.contains("icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR"), - "V3 状态图标显式 LINEAR 不丢(status_slot_row.gd)") - - -# ---- <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) - check(r_order >= 4.5, "V4 顺位徽标/重甲徽标 GOLD_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" % r_order) - var r_ward := _theme_contrast(uikit_s.HERO_BLUE_BRIGHT, stage) - check(r_ward >= 4.5, "V4 魔法护甲徽标 HERO_BLUE_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" % r_ward) - var r_rap := _theme_contrast(uikit_s.STONE_DEEP, uikit_s.PANEL_DATA) - check(r_rap >= 4.5, "V4 RAP 反馈行 STONE_DEEP 对数据板 %.2f:1 ≥4.5:1" % r_rap) - # 消费点源码锚定:新墨在位、旧裸用点清零、语义岗常量不动 - var sb_src: String = FileAccess.get_file_as_string("res://presentation/views/standee_body.gd") - check(sb_src.contains("HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_BRIGHT"), - "V4 顺位徽标①②③ 墨=GOLD_BRIGHT(13px 行原位)") - check(sb_src.contains("col = UiKit.HERO_BLUE_BRIGHT"), "V4 魔法护甲徽标墨=HERO_BLUE_BRIGHT") - check(sb_src.contains("col = UiKit.GOLD_BRIGHT"), "V4 重甲徽标墨=GOLD_BRIGHT") - check(sb_src.contains("if _intent_col == UiKit.GOLD_DEEP:"), - "V4 GOLD_DEEP 语义岗不动(standee:199 意图动画判据)") - var ap_src: String = FileAccess.get_file_as_string("res://presentation/views/ap_bar_view.gd") - check(ap_src.contains('UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)'), - "V4 RAP 反馈行墨=STONE_DEEP(原位行锚定)") - check(not ap_src.contains("STONE_FAINT"), "V4 RAP 反馈行旧墨 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) - check(r_white >= 3.0, "T5② BCard 图标槽白图标对比 %.2f:1 ≥3:1(硬约束)" % r_white) - 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) - check(r_new < r_old, "T5② 明度差降档生效(槽对卡面 %.2f:1 < 旧 %.2f:1)" % [r_new, r_old]) - var uk_src: String = FileAccess.get_file_as_string("res://presentation/views/ui_kit.gd") - check(uk_src.contains("UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)"), - "T5② BCard 图标槽落柔和档(SLOT_BACKDROP_SOFT)") - var ss_src: String = FileAccess.get_file_as_string("res://presentation/views/status_slot_row.gd") - check(ss_src.contains("UiKit.SLOT_BACKDROP"), "T5② B 区状态图标槽仍用深档(SLOT_BACKDROP,未误伤)") - # ③布局修源码锚定 - var shop_src: String = FileAccess.get_file_as_string("res://presentation/views/shop_overlay_view.gd") - check(shop_src.contains("_cards_box.alignment = BoxContainer.ALIGNMENT_CENTER"), - "T5③ 商店卡片区垂直居中(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)") - check(camp_min == 3, "T5③ 营地三 holder 统一 min 高 336(实测 %d 处)" % camp_min) - var camp_fill := camp_src.count("size_flags_vertical = Control.SIZE_FILL") - check(camp_fill == 4, "T5③ 营地四栏面板纵向 FILL 等高(实测 %d 处)" % camp_fill) - check(camp_src.count("SIZE_SHRINK_CENTER") >= 3, "T5③ 三处锁文垂直居中在位") - - -# ---- 诊断转储钮冒烟(角落钮不在玩家画面+转储入口在调试浮窗;V6)---- -func _smoke_diag_button_retired() -> void: - print("--- UI 诊断转储钮退役(V6:玩家画面清零+浮窗可达)---") - var main_src: String = FileAccess.get_file_as_string("res://presentation/main.gd") - check(not main_src.contains("build_diag_button"), "V6 主区无 build_diag_button 调用残留(旧实现同批清理)") - check(main_src.contains("debug_overlay.diagnose_requested.connect(_on_diagnose_pressed)"), - "V6 转储入口连接随迁(组合根接浮窗 diagnose_requested 信号)") - check(main_src.contains("func _on_diagnose_pressed()"), "V6 转储编排承接函数在位(dump_diagnosis 卡死取证保留)") - var asm_src: String = FileAccess.get_file_as_string("res://presentation/ui_assembler.gd") - check(not asm_src.contains("build_diag_button"), "V6 build_diag_button 工厂已退役(ui_assembler 零残留)") - var dbg_src: String = FileAccess.get_file_as_string("res://presentation/views/debug_overlay.gd") - check(dbg_src.contains("signal diagnose_requested"), "V6 调试浮窗 diagnose_requested 信号在位") - check(dbg_src.contains("_emit_diagnose_requested"), "V6 浮窗转储钮外发方法在位(纯视图边界)") - check(dbg_src.contains('UiKit.button("转储")'), "V6 浮窗诊断转储钮在位(玩家画面角落钮清零后唯一入口)") - - -# ---- 存档序列化冒烟(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}) - check(w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0, - "apply 原语入账(钥石 7+3、通关次数 1/1/0)") - check(w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3), - "首杀标记随通关关卡置位(1/2 有、3 无)") - check(w.adventure_total() == 1 and w.adventure_cleared() == 0, - "生涯终身计数随 apply 自增恰一(中止局:总局数 1、通关局数 0——迭代 18 卡④)") - check(w.write_to_disk(), "写盘成功") - var r = sm_script.new(paths[0]) - check(r.load_from_disk(), "读回成功") - check(r.keystone_balance() == 10, "钥石余额往返相等(10)") - check(r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b", - "树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)") - check(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", - "锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕") - check(r.item_unlocked("probe_compass") and not r.item_unlocked("rope"), "道具解锁往返相等") - check(r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0, "通关次数表往返相等") - check(r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3), - "首杀标记往返相等") - check(r.adventure_total() == 1 and r.adventure_cleared() == 0, "生涯终身计数往返相等(1/0)") - var s0: Dictionary = r.recent_summaries()[0] - check(r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3, - "冒险摘要往返相等(1 条,seed 99)") - - # ② 新档合法:不存在路径 load → true + 全零默认值 - var f2 = sm_script.new(paths[1]) - check(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(), - "新档合法态(load true + 全零默认)") - check(f2.adventure_total() == 0 and f2.adventure_cleared() == 0, - "新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)") - - # ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外) - var f3 = sm_script.new(paths[2]) - f3.add_keystone(5) - check(not f3.spend_keystone(8) and f3.keystone_balance() == 5, "余额 5 花 8 拒绝且余额不动") - check(f3.spend_keystone(3) and f3.keystone_balance() == 2, "余额内消费成功(5→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]) - check(not f5.load_from_disk(), "高版本拒读(load false)") - check(FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, "拒读后原文件字节不动") - 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]) - check(f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty(), - "损坏档兜底(默认数据继续)") - check(FileAccess.file_exists(paths[4] + ".corrupt.bak"), "坏档备份 .corrupt.bak 存在") - 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() - check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)") - check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7, - "迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)") - - # ⑧ 域界干净: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) - check(leaked.is_empty(), "域界干净——meta 域无局内资源键(发现 %s)" % str(leaked)) - - # ⑩ 摘要 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() - check(arr.size() == 10, "摘要 cap 10(12 份 → 10 条)") - check(int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2, - "最新在前(头 seed 11 / 尾 seed 2)") - - # ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持 - var autoplay = load("res://smoke/level_autoplay.gd") - var st9: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) - var f9 = sm_script.new(paths[8]) - 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)) - check(String(st9.get("result", "")) == "completed", "摘要集成:冒险驱动满配通关(seed 42)") - check(f9.keystone_balance() == ks_run, "集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance()) - check(f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1, "集成通关次数表全 1") - check(f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3), - "集成三关首杀标记齐") - check(f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42, - "集成 recent 1 条且 seed 42") - var st9b: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) - f9.apply_adventure_summary(st9b.get("summary", {})) - check(f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2, "第二局后通关次数全 2") - check(f9.keystone_balance() == 2 * ks_run, "第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)") - check(f9.recent_summaries().size() == 2, "集成 recent 长度 2") - check(f9.write_to_disk(), "集成写盘成功") - var r9 = sm_script.new(paths[8]) - 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), - "集成写盘读回全量保持") - - # ⑪ 断点域:七字段写读往返逐位一致+附随件+值拷贝出户+清除+旧档迁移合法空态 - var fbp = sm_script.new(paths[9]) - 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}], - }) - check(fbp.has_breakpoint() and fbp.write_to_disk(), "断点:写入+写盘成功") - var rb = sm_script.new(paths[9]) - check(rb.load_from_disk() and rb.has_breakpoint(), "断点:读回在档") - var bp: Dictionary = rb.breakpoint_snapshot() - 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", {}) - 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, "断点七字段往返:属性加成区") - check(int(bp.get("gp", -1)) == 17, "断点七字段往返:GP 钱包") - var bp_shop: Dictionary = bp.get("shop_purchased", {}) - check(int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1, - "断点七字段往返:商店已购档") - check(int(bp.get("template_pick", -1)) == 3, "断点七字段往返:已抽模板") - 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", []) - 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", []) - check(bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout", - "断点附随件往返:道具库存") - bp["hero_hp"] = 999 - check(int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13, - "断点读面值拷贝(改读面不渗档)") - rb.clear_breakpoint() - check(not rb.has_breakpoint() and rb.write_to_disk(), "断点:清除+写盘(三结局收束口径)") - var rc = sm_script.new(paths[9]) - 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() - check(fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint(), - "v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)") - check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7, - "v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)") - - # ⑫ 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() - check(fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7, - "v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)") - check(int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777, - "v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)") - var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777)) - check(adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}), - "v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)") - - # ⑬ 生涯终身计数域: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() - check(f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 - and f13.keystone_balance() == 9, - "v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)") - check(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, - "升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)") - 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}) - check(f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1, - "收束自增恰一·通关局(三关全通 cleared=true:总局数+1 且通关局数+1)") - f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, - "stopped_at": 2, "keystone_total": 0, "master_seed": 2}) - check(f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1, - "收束自增恰一·中止局(放弃局:仅总局数+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() - check(f15.load_from_disk() and f15.keystone_balance() == 33 - and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty(), - "换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动") - var mp16 = load("res://core/meta_progression.gd").new(_db, f15) - f15.add_keystone(50) - 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() - check(f16.load_from_disk() and f16.keystone_balance() == 21 - and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1, - "v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕") - check(f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 - and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 - and f16.forge_unlocked_purchases().is_empty() - and f16.forge_weapon() == "sword" and f16.forge_armor() == "light", - "v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕") - var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys() - fr_keys.sort() - check(fr_keys == ["armor", "levels", "unlocked", "weapon"], - "forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕") - var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys() - lv_keys.sort() - check(lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"], - "levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕") - # 值拷贝出户(改快照不渗档——本类契约) - var snap: Dictionary = f16.forge_routes_snapshot() - (snap.get("levels", {}) as Dictionary)["sword"] = 99 - check(f16.forge_route_level("sword") == 0, "forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕") - - # ⑭ 收束链入账(真实收束挂点全链恰一:主流程 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}) - check(f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1, - "收束链自增·中止局(settle_adventure 真实挂点:总局数+1、通关局数不动)") - mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3, - "stopped_at": 0, "keystone_total": 0, "master_seed": 4}) - check(f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2, - "收束链自增·通关局(三关全通:总局数+1 且通关局数+1——全链恰一)") - - # 段尾清理(测试文件绝不残留) - 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): - check(t.activate_slot(i), "sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, - "stopped_at": 0, "keystone_total": 100 * i, "master_seed": i}) - check(t.write_to_disk(), "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i) - var t2 = sm_script.new() - t2.attach_slot_template(slot_tpl) - for i in range(1, 4): - check(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), - "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i) - var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) - t.activate_slot(1) - t.add_keystone(7) - check(t.write_to_disk(), "sv3 写盘受理·槽 1 加值后〔卡① sv1〕") - check(FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before, - "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕") - - # ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动) - check(t.activate_slot(3), "sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕") - check(t.delete_slot(3), "sv6 删档受理·激活槽 3〔卡① sv6〕") - check(not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp"), - "sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕") - check(t.active_slot() == 0 and not t.has_write_target(), - "sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕") - var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1) - t.activate_slot(1) - check(t.delete_slot(2), "sv6 删非激活槽受理·槽 2〔卡① sv6〕") - check(not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1, - "sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕") - check(FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before, - "sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕") - - # ③ 新档原语(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() - check(t.create_slot(3), "sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕") - check(t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() - and t.adventure_total() == 0, - "sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕") - var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3)) - check(int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0, - "sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕") - - # ④ 高版本拒读槽按槽成立+激活失败恢复切换前三态(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) - check(hv.activate_slot(1) and hv.keystone_balance() == 107, "sv5 进档槽 1 读回(107)〔卡① sv10〕") - var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2) - check(not hv.activate_slot(2), "sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕") - check(hv.active_slot() == 1 and hv.keystone_balance() == 107, - "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕") - hv.add_keystone(1) - check(hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv, - "sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕") - - # ⑤ 迁移链各槽成立(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) - check(mi.activate_slot(i) and mi.keystone_balance() == i * 11 - and mi.write_to_disk(), - "sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i) - - # ⑥ 窥视三态零副作用(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) - check(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, - "sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" % i) - check(pv.delete_slot(3), "sv6 删槽 3 备空槽窥视〔卡① sv6〕") - var pe: Dictionary = pv.peek_slot_summary(3) - check(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, - "sv7 空槽窥视=零值合法态〔卡① sv7〕") - 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) - check(not bool(pb.get("readable", true)), "sv8 坏槽窥视=显式损坏标记〔卡① sv8〕") - check(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"), - "sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕") - 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) - check(not bool(ph.get("readable", true)), "sv8 高版本槽窥视=不可读标记〔卡① sv8〕") - check(FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes - and FileAccess.get_modified_time(slot_tpl % 2) == hv_mtime, - "sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕") - - # ⑦ 旧单档迁移(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)) - check(int((migrated.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234, - "sv12 旧档字节迁入槽 1(逐域相等——余额 1234)〔卡① sv12〕") - check(not FileAccess.file_exists(legacy_src) and FileAccess.file_exists(legacy_src + ".migrated.bak"), - "sv12 迁移后源改名留痕(.migrated.bak 在盘、原路径不在)〔卡① sv12〕") - 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)) - check(int((slot1_now.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 - and FileAccess.file_exists(legacy_src), - "sv12 幂等:槽 1 已存在不重迁(预置 777 零覆盖、源保留)〔卡① sv12〕") - check(FileAccess.get_file_as_bytes(slot_tpl % 2) == s2_before - and FileAccess.get_file_as_bytes(slot_tpl % 3) == s3_before, - "sv12 迁移他槽零触碰(槽 2/3 字节不动)〔卡① sv12〕") - - # 段尾清理(测试文件绝不残留) - for p in stale: - if FileAccess.file_exists(p): - DirAccess.remove_absolute(p) diff --git a/game/smoke/smoke_all.gd.uid b/game/smoke/smoke_all.gd.uid deleted file mode 100644 index 35e1a7fe..00000000 --- a/game/smoke/smoke_all.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4a3130vctg4d diff --git a/game/tests/README.md b/game/tests/README.md index dd91fb24..f400bc65 100644 --- a/game/tests/README.md +++ b/game/tests/README.md @@ -4,7 +4,7 @@ **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/`(场景探针族——正式工程的活样板;系统测试断言面过渡宿主 `smoke_all.gd` 亦在此,迁移归断言迁移批) +**参考实现**: `game/probe/`(场景探针族)· `game/sim/`(测量/诊断/模拟驱动件)(场景探针族——正式工程的活样板;系统测试断言面过渡宿主 `smoke_all.gd` 亦在此,迁移归断言迁移批) ## Directory Layout @@ -27,8 +27,8 @@ tests/ | **冒烟测试** | 这版活着吗——能否启动、能否加载表、能否进主屏。零逻辑判定 | `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) | +| **系统测试** | 启动真实工程走真实玩法路径 | 过渡期 `game/probe/smoke_all.gd`(断言迁移批后=`tests/system_test.gd`) | `run_system_test.py`(闸 01) | +| **UI 探针+渲染 golden** | 跑起来之后 UI 与场景切换对不对;画面基线 | `game/probe/*.tscn` 探针 | `probe_gate.sh`(闸 03/04)、`render_gate.sh`(闸 05) | > **integration/ 已建承载**(迭代 29 T3——此前只有章程句无实体,空壳曾于迭代 19 卡① T4 删过一次)。 > 首个集成用例 = `save_rng_roundtrip_test.gd`(存档⇄RNG 快照往返)。章程登记的四类集成用例里, @@ -61,7 +61,7 @@ godot --headless --script tests/unit/example_test.gd python tools/run_system_test.py # 场景探针(UI 探针层)——以探针场景为运行目标,走完整项目引导(autoload 在位) -godot --headless --path . res://smoke/.tscn +godot --headless --path . res://probe/.tscn # 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置) timeout 120s godot --headless --script tests/run_all.gd @@ -94,9 +94,9 @@ timeout 120s godot --headless --script tests/run_all.gd | UI | Manual walkthrough OR interaction test(场景探针为首选) | `tests/evidence/` | | Config/Data | Smoke test pass | 跑批回执 `game/tests/.gates/receipt.json`(不入库)+结论随批记 `reports/` | -## game/smoke/ 游离脚本台账(迭代 29 T7 逐个定性) +## game/probe/ 游离脚本台账(迭代 29 T7 逐个定性) -`game/smoke/` 里的脚本两分:**在闸件**(探针有 `.tscn` 宿主、被 `probe_gate.sh`/`render_gate.sh` 调用; +`game/probe/` 里的脚本两分:**在闸件**(探针有 `.tscn` 宿主、被 `probe_gate.sh`/`render_gate.sh` 调用; `smoke_all.gd` 被系统测试运行器调用——过渡期宿主)与**不入闸的手动测量/诊断件**(下表)。 判据 = 头注自陈用途 + 有无外部引用(工具脚本/产品码/文档)。**存疑一律按在役登记并标注**(不退役)。 @@ -158,6 +158,6 @@ S12 效果引擎无状态化旧件零残留:扫描 game/ 全域 `.gd` 与 `gam | **冒烟测试** | 秒级存活检查——能否启动、能否加载表、能否进主屏。判断「这版还活着吗」,不判断「这版对不对」 | `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` | +| **系统测试** | 启动真实工程走真实玩法路径的断言面 | 过渡期 `game/probe/smoke_all.gd`+`tools/run_system_test.py`;迁移后 `tests/system_test.gd` | +| **场景探针** | 以探针场景为运行目标、走完整项目引导(autoload 在位)的 UI/场景断言 | `game/probe/*.tscn`、`probe_gate.sh` | | **渲染 golden** | 窗口化截图与基线图的逐像素容差比对 | `render_gate.sh`、`tests/golden/` | diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd index 83a30d7e..8543e0ab 100644 --- a/game/tests/integration/cross_system_flow_test.gd +++ b/game/tests/integration/cross_system_flow_test.gd @@ -14,7 +14,7 @@ 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——账本 §五夹具前提) +const LevelAutoplay := preload("res://sim/level_autoplay.gd") # 真局驱动(迁移期按现路径 preload——账本 §五夹具前提) var _db diff --git a/game/tests/static_scan.sh b/game/tests/static_scan.sh index 62c66885..7c908d7c 100644 --- a/game/tests/static_scan.sh +++ b/game/tests/static_scan.sh @@ -97,7 +97,7 @@ fi # 禁「板级」「门票」「正主」「落籍」「完美排雷」等退役词进注释面(完整词表见术语表解析) # 权威出处:design/gdd/术语表.md「退役词对照」节+「对外命名纪律」节 # 同形异义豁免:机制词「去重闸」(封印重判去重的玩法判别器)与某闸档退役词后二字同形—— -# 行内含「去重闸」即整行跳过(2026-10-22 测试术语重构批随闸档用语入表增设,否则 smoke_all.gd +# 行内含「去重闸」即整行跳过(2026-10-22 测试术语重构批随闸档用语入表增设,否则 adventure_feats_test.gd # 在册断言标签成片误红;代价=该行同时出现的真闸档用词也不报,靠文档域 08 闸兜住) # 扫描面:game/ 全域 .gd/.tres/.md/.json/.sh(.txt 不收=第三方许可证非本仓库文本) # 注释文本提取:.gd/.sh 取 # 后文本;.tres 取 ; 后文本;.md 取整行;.json 无注释语法(保留扩展名但不产生注释段) diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd index 01ef32c6..bfd2ea01 100644 --- a/game/tests/system_test.gd +++ b/game/tests/system_test.gd @@ -1,6 +1,6 @@ # tests/system_test.gd — 系统测试宿主薄壳(SceneTree+构造注入) # 系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一,闸 01)。 -# 本件现宿主零断言——smoke_all.gd 的断言按「只剪切禁改写」纪律迁入(断言迁移批执行, +# 本件为系统测试宿主(标准四级测试层之一,闸 01)。(原过渡期自述作废——, # 本批只落宿主薄壳,跑通即绿)。迁移完成后 tools/run_system_test.py 缺省 --script 改指本件。 # 协议照单元侧惯例:extends SceneTree 获得 quit(code);--script 模式不加载 autoload, # 运行形态=--script 直跑+autoload 分派闸(ConfigDB/SaveManager 在座才进主断言面——闸 01 实跑 65 项 0 失败实证在案);摘要行由 tools/run_system_test.py 三闸判定 @@ -8,7 +8,7 @@ # 运行: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) +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(lifecycle 活战斗驱动;smoke 库件迁移期按现路径 preload) var _failures: int = 0 var _checks: int = 0 @@ -24,7 +24,7 @@ func check(cond: bool, label: String) -> void: func _initialize() -> void: - print("=== Dungeon Sweeper 系统测试(宿主薄壳——断言面待迁移批自 smoke_all.gd 迁入) ===") + print("=== Dungeon Sweeper 系统测试 ===") _split_tail() diff --git a/game/tests/unit/battle_settlement/roll_multiplier_test.gd b/game/tests/unit/battle_settlement/roll_multiplier_test.gd index b89ed3ea..9fb8ecd1 100644 --- a/game/tests/unit/battle_settlement/roll_multiplier_test.gd +++ b/game/tests/unit/battle_settlement/roll_multiplier_test.gd @@ -13,7 +13,7 @@ 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——账本 §五夹具前提) +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) var _db diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index 52526491..6a6b0428 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -15,7 +15,7 @@ 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——账本 §五夹具前提) +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) var _db diff --git a/game/tests/unit/battle_settlement/seal_polarity_test.gd b/game/tests/unit/battle_settlement/seal_polarity_test.gd index 637d0a1b..82b7390e 100644 --- a/game/tests/unit/battle_settlement/seal_polarity_test.gd +++ b/game/tests/unit/battle_settlement/seal_polarity_test.gd @@ -16,7 +16,7 @@ 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——账本 §五夹具前提) +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) var _db diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index 1d606162..a19ac9d1 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -68,7 +68,7 @@ func _smoke_adventure_api() -> void: 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 autoplay = load("res://sim/level_autoplay.gd") var battle = adr.runner().current_battle() var st := _battle_st("normal") var guard := 30 @@ -107,7 +107,7 @@ func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void: # 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃) func _smoke_adventure() -> void: print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---") - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言; # enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域, # 同带多行合法,带键聚合天然兼容〔fv24〕) diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 0800bd1e..af4469a8 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -44,7 +44,7 @@ func _init() -> void: func _smoke_level() -> void: print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---") - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) # ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖) var win = autoplay.run_level(_db, 42, 1, 200, 100) @@ -91,7 +91,7 @@ func _battle_st(policy: String) -> Dictionary: func _smoke_level_graph() -> void: print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") # ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述) Kit.check("LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误", _db.level.validate().is_empty()) # ② L2 行集结构(网状图建议规模) @@ -382,7 +382,7 @@ func _fresh_rng(seed_val: int): func _smoke_gp_wallet() -> void: print("--- GP 冒险钱包·到账(战斗完成入账恰一次 + Boss 加值)---") - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") # ① L1 首战(普通战档——掷定 [2,4] + 关级加值 0):runner 驱动,收集 battle_finished 与钱包事件 var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) runner.set_hero_override(200, 100) @@ -560,7 +560,7 @@ func _find_gp_dig(minesweeper, settlement) -> Dictionary: func _smoke_shop_room() -> void: print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---") - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") # 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) var shop_run := {} for s in range(42, 92): @@ -721,7 +721,7 @@ func _smoke_shop_graph() -> void: func _smoke_shop_pool() -> void: print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---") - var autoplay = load("res://smoke/level_autoplay.gd") + var autoplay = load("res://sim/level_autoplay.gd") var shelf_kinds: Array = [] for row in _db.shop_pool_rows(): shelf_kinds.append(String(row.get("kind_id", ""))) diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index 098ff6c7..7bbe65c2 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -15,7 +15,7 @@ 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——账本 §五夹具前提) +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) var _db diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index 67a2fba0..e60e5778 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -49,7 +49,7 @@ func _init() -> void: # 理性策略挖到触发结算(≥1 行入队即达断言前提——AP 一次消耗;挖格须邻英雄——邻接优先排序) var ms = battle.minesweeper # 理性策略驱动(auto_player 单一实现复用——damage_probe 同款两步消费:先 move 到 hero_cell 再 dig) - var ap = load("res://smoke/auto_player.gd") + var ap = load("res://sim/auto_player.gd") var acted := false for guard in range(40): var plan: Dictionary = ap.choose_dig(ms) diff --git a/game/tests/unit/presentation/theme_projection_test.gd b/game/tests/unit/presentation/theme_projection_test.gd index c63b6838..f78df38f 100644 --- a/game/tests/unit/presentation/theme_projection_test.gd +++ b/game/tests/unit/presentation/theme_projection_test.gd @@ -22,7 +22,7 @@ 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——账本 §五夹具前提) +const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提) var _db var _rng diff --git a/sim-data/README.md b/sim-data/README.md index 556ec876..98b3028d 100644 --- a/sim-data/README.md +++ b/sim-data/README.md @@ -33,7 +33,7 @@ ## 工具与格式 -- 采集:`game/smoke/adventure_monte_carlo.gd`(git)——参数 n/seed0/policy,seed 逐局独立 +- 采集:`game/sim/adventure_monte_carlo.gd`(git)——参数 n/seed0/policy,seed 逐局独立 - 锚格式:stdout 捕获,每局一行 `RUNSUM` JSON(81000 锚=800 行=400×2 档);自 2026-10-03 档位规起新锚统一=去前缀纯 JSONL 按种子升序(旧形态 analyze.py 直读会炸,见上「MC 采集档位表」配套纪律⑧) - 分析:`analyze.py`+`growth_model.py`(git)。⚠ analyze.py 默认输入名系首批遗留(runsum_600.jsonl 已不存在),跑现行锚须 `RUNSUM=<锚文件>` 指定,其完成度%段对现行锚未验证 - fulllog 全量日志不留盘(.gitignore 排除);`iteration_log.md` 系 Package B 时代批日志留盘不入库;新模拟批的读数与结论写在该批的 git 提交信息与 `reports/` 报告里(2026-10-18 用户裁:根目录会话状态文件已废除) diff --git a/sim-data/analyze.py b/sim-data/analyze.py index c7eb3064..71eb7b6d 100644 --- a/sim-data/analyze.py +++ b/sim-data/analyze.py @@ -179,7 +179,7 @@ def main(): calib_wh[(p['w'], p['h'])].append(p['cleared_safe']) safe_total_map = {k: int(st.median(v)) for k, v in calib.items()} safe_by_wh = {k: int(st.median(v)) for k, v in calib_wh.items()} - # 生成器真值分母(smoke/calibrate_boards.gd 200 板/行——权威口径;日志清图校准仅作交叉核对) + # 生成器真值分母(sim/calibrate_boards.gd 200 板/行——权威口径;日志清图校准仅作交叉核对) gen_safe = {} gen_r0 = {} with open(BASE + 'board_calibration.csv', encoding='utf-8') as f: diff --git a/tools/audit_type_hints.py b/tools/audit_type_hints.py index 2c2d7a1a..09407b20 100644 --- a/tools/audit_type_hints.py +++ b/tools/audit_type_hints.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """audit_type_hints.py — 类型闸机器审计(code-conventions §4 语言纪律的机器闸)。 -产品码 = game/core + game/data + game/presentation(smoke/tests 豁免——测量面容器直构是惯例)。 +产品码 = game/core + game/data + game/presentation(probe/sim/tests 不在扫描面——测量面容器直构是惯例)。 三条规则: R1 无类型容器声明:显式 Dictionary/Array 非参数化类型注解(审计 §2.2「73 处」同口径—— `:= {}` 推断形态属类型推断合法面不算债)。 diff --git a/tools/balance_verifier.py b/tools/balance_verifier.py index f83463b3..abba7cd4 100644 --- a/tools/balance_verifier.py +++ b/tools/balance_verifier.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测量,替代「亲手试玩再反馈」循环。 -两段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定): +两段采集(数据源 = sim/verifier_batch.gd,本文件驱动它并做全部判定): adventure 段 全冒险连探基线(零永久强化)——逐关条件通关率派生 + 止步分布 + 败退节点归因(层/节点/敌人); adventure 段支持 inject 注入档(永久强化 flat OFAT——与白板同种子流配对,行带 inject_tag/override,标定面 = 逐关条件通关率差) @@ -51,7 +51,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SLICE = ROOT / "game" GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" -SCRIPT = "res://smoke/verifier_batch.gd" +SCRIPT = "res://sim/verifier_batch.gd" READINGS_DIR = ROOT / "sim-data" VJSON_PREFIX = "VJSON " ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") diff --git a/tools/clear_dominance_audit.py b/tools/clear_dominance_audit.py index 5589fcba..085250da 100644 --- a/tools/clear_dominance_audit.py +++ b/tools/clear_dominance_audit.py @@ -84,7 +84,7 @@ DELTA = 0.25 # 板形(|M| 幸存雷 / |S| 安全格)双口径 # 名义 = density×(W×H−9) 初布值(参考板形口径,连续性参照) -# 实测 = smoke/board_stat_probe.gd 200 样本/行均值("先布后删"落地值,主流读数) +# 实测 = sim/board_stat_probe.gd 200 样本/行均值("先布后删"落地值,主流读数) # (实测值沿用 board_stat_probe 读数;板形行值未变,不强制复跑) BOARDS = { "L1前段·名义(10x10)": {"M": 23, "S": 77}, diff --git a/tools/golden_baseline_refresh.sh b/tools/golden_baseline_refresh.sh index 09a92c79..93ad7d7f 100644 --- a/tools/golden_baseline_refresh.sh +++ b/tools/golden_baseline_refresh.sh @@ -36,7 +36,7 @@ if [ "$MODE" = "menu" ]; then exit 1 fi done - timeout 240 "$G" --path "$GAME_WIN" res://smoke/shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1 + timeout 240 "$G" --path "$GAME_WIN" res://probe/shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1 st=$? grep -E "\[shot_probe\]" "$TMP/run.log" | tail -3 if [ "$st" -ne 0 ] || [ ! -s "$TMP/shots/shot_menu.png" ]; then @@ -61,7 +61,7 @@ for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ fi done -timeout 180 "$G" --path "$GAME_WIN" res://smoke/board_shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1 +timeout 180 "$G" --path "$GAME_WIN" res://probe/board_shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1 st=$? grep -E "\[board_shot\]" "$TMP/run.log" | tail -6 if [ "$st" -ne 0 ] || [ ! -s "$TMP/shots/board_meta.json" ]; then diff --git a/tools/parity_anchor.py b/tools/parity_anchor.py index a5c7605c..80948c61 100644 --- a/tools/parity_anchor.py +++ b/tools/parity_anchor.py @@ -52,7 +52,7 @@ def run_variant(name: str, seed0: int, attune: str, shop: str, timeout: int = 56 """跑一个变体批,返回 (runsum_lines, {policy: jsonl绝对路径})。""" before = set(p.name for p in USER_LOGS.glob("playtest_*.jsonl")) if USER_LOGS.is_dir() else set() cmd = [GODOT, "--headless", "--path", str(GAME_DIR), "--script", - "res://smoke/adventure_monte_carlo.gd", "--", + "res://sim/adventure_monte_carlo.gd", "--", str(N_RUNS), str(seed0), "both", "min", attune, shop, "off"] proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index fb8a0e2c..1098059d 100644 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -86,38 +86,38 @@ pre="$(orphans | wc -l | tr -d ' ')" if [ "$pre" -gt 0 ]; then echo "[probe_gate] 预清本闸残留 Godot×$pre"; kill_orphans; fi echo "======== ui_input_probe ========" -timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_input_probe.tscn >"$TMP/uip.log" 2>&1 +timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_input_probe.tscn >"$TMP/uip.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12 verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1 echo "======== ui_struct_probe ========" -timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_struct_probe.tscn >"$TMP/stp.log" 2>&1 +timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_struct_probe.tscn >"$TMP/stp.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL" "$TMP/stp.log" | head -12 verdict "ui_struct_probe" "$TMP/stp.log" "$st" || _rc=1 echo "======== layout_probe(分辨率几何·两档) ========" -timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/layout_probe.tscn >"$TMP/lay.log" 2>&1 +timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/layout_probe.tscn >"$TMP/lay.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL" "$TMP/lay.log" | head -12 verdict "layout_probe" "$TMP/lay.log" "$st" || _rc=1 echo "======== ui_contrast_probe ========" -timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_contrast_probe.tscn >"$TMP/ctp.log" 2>&1 +timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_contrast_probe.tscn >"$TMP/ctp.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL" "$TMP/ctp.log" | head -12 verdict "ui_contrast_probe" "$TMP/ctp.log" "$st" || _rc=1 echo "======== screen_flow_probe(L3 屏壳切换·迭代 29 T4) ========" -timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/screen_flow_probe.tscn >"$TMP/sfp.log" 2>&1 +timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/screen_flow_probe.tscn >"$TMP/sfp.log" 2>&1 st=$?; kill_orphans grep -E "screen_flow_probe" "$TMP/sfp.log" | tail -3 verdict "screen_flow_probe" "$TMP/sfp.log" "$st" "screen_flow_probe" || _rc=1 if [ "$WITH_CLICK" -eq 1 ]; then echo "======== ui_click_probe 行为走查 ========" - timeout 240 "$G" --headless --path "$GAME_WIN" res://smoke/ui_click_probe.tscn >"$TMP/clk.log" 2>&1 + timeout 240 "$G" --headless --path "$GAME_WIN" res://probe/ui_click_probe.tscn >"$TMP/clk.log" 2>&1 st=$?; kill_orphans grep -E "click_probe" "$TMP/clk.log" | tail -4 verdict "ui_click_probe" "$TMP/clk.log" "$st" "click_probe" || _rc=1 @@ -127,25 +127,25 @@ fi # ui_tune_probe 49.6s/t17_defer_probe 63.0s/fx_probe 21.9s,timeout 120=2-7×余量) ── if [ "$WITH_FULL" -eq 1 ]; then echo "======== ui_probe(--full 收编) ========" - timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_probe.tscn >"$TMP/flp.log" 2>&1 + timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_probe.tscn >"$TMP/flp.log" 2>&1 st=$?; kill_orphans grep -E "\[ui_probe\]" "$TMP/flp.log" | tail -2 verdict "ui_probe" "$TMP/flp.log" "$st" || _rc=1 echo "======== ui_tune_probe(--full 收编) ========" - timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_tune_probe.tscn >"$TMP/flt.log" 2>&1 + timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_tune_probe.tscn >"$TMP/flt.log" 2>&1 st=$?; kill_orphans grep -E "\[tune_probe\]" "$TMP/flt.log" | tail -2 verdict "ui_tune_probe" "$TMP/flt.log" "$st" || _rc=1 echo "======== t17_defer_probe(--full 收编) ========" - timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/t17_defer_probe.tscn >"$TMP/fld.log" 2>&1 + timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/t17_defer_probe.tscn >"$TMP/fld.log" 2>&1 st=$?; kill_orphans grep -E "\[t17_defer\]" "$TMP/fld.log" | tail -2 verdict "t17_defer_probe" "$TMP/fld.log" "$st" || _rc=1 echo "======== fx_probe(--full 收编) ========" - timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/fx_probe.tscn >"$TMP/flx.log" 2>&1 + timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/fx_probe.tscn >"$TMP/flx.log" 2>&1 st=$?; kill_orphans grep -E "\[fx_probe\]" "$TMP/flx.log" | tail -2 verdict "fx_probe" "$TMP/flx.log" "$st" || _rc=1 diff --git a/tools/render_gate.sh b/tools/render_gate.sh index b0cef79d..b39e209a 100644 --- a/tools/render_gate.sh +++ b/tools/render_gate.sh @@ -53,7 +53,7 @@ if [ "$pre" -gt 0 ]; then echo "[render_gate] 预清本闸残留 Godot×$pre"; k echo "======== shot_probe 七屏截图 → shot_checker A-D ========" SHOT="$TMP/shots" -timeout 180 "$G" --path "$GAME_WIN" res://smoke/shot_probe.tscn -- "$SHOT" >"$TMP/shot.log" 2>&1 +timeout 180 "$G" --path "$GAME_WIN" res://probe/shot_probe.tscn -- "$SHOT" >"$TMP/shot.log" 2>&1 st=$?; kill_orphans grep -E "\[shot_probe\]" "$TMP/shot.log" | tail -12 if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then @@ -69,7 +69,7 @@ fi echo "======== fx_shot_probe 三时刻取证(Q7) ========" FX="$TMP/fxshots" -timeout 180 "$G" --path "$GAME_WIN" res://smoke/fx_shot_probe.tscn -- "$FX" >"$TMP/fx.log" 2>&1 +timeout 180 "$G" --path "$GAME_WIN" res://probe/fx_shot_probe.tscn -- "$FX" >"$TMP/fx.log" 2>&1 st=$?; kill_orphans grep -E "\[fx_shot_probe\]" "$TMP/fx.log" | tail -4 if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then @@ -93,7 +93,7 @@ fx_evidence "fx_final.png" 1 echo "======== board_shot_probe L1-L3 取证(Q7) ========" BD="$TMP/boardshots" -timeout 180 "$G" --path "$GAME_WIN" res://smoke/board_shot_probe.tscn -- "$BD" >"$TMP/bd.log" 2>&1 +timeout 180 "$G" --path "$GAME_WIN" res://probe/board_shot_probe.tscn -- "$BD" >"$TMP/bd.log" 2>&1 st=$?; kill_orphans grep -E "\[board_shot\]" "$TMP/bd.log" | tail -6 if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then diff --git a/tools/repo_structure_audit.py b/tools/repo_structure_audit.py index 9b1d1f09..2132364d 100644 --- a/tools/repo_structure_audit.py +++ b/tools/repo_structure_audit.py @@ -38,7 +38,7 @@ IMAGE_PREFIXES = ("artwork/", "game/assets/", "design/art/references/", "design/ # 4) 退役路径(目录或文件任一再现即 FAIL) RETIRED = ["production", "docs", "tests", "qa-data", "design/registry", "design/research", "design/art/candidates", "production/mockups", - "design/ux", "game/tools", "design/qa"] + "design/ux", "game/tools", "design/qa", "game/smoke"] # 5) design/ 一级子目录白名单(防 ad-hoc 新目录) DESIGN_SUBDIRS = {"art", "engineering", "gdd", "content-archive"} # 6) design/art/ 内容白名单——美术只放 .md 文档与 references/ 参考图; diff --git a/tools/run_all_gates.sh b/tools/run_all_gates.sh index 5dbae261..51183941 100644 --- a/tools/run_all_gates.sh +++ b/tools/run_all_gates.sh @@ -206,7 +206,7 @@ game/core/settlement/ game/core/damage_math.gd game/core/damage_controller.gd game/core/battle_orchestrator.gd -game/smoke/adventure_monte_carlo.gd +game/sim/adventure_monte_carlo.gd " _base="$(git -C "$REPO" merge-base master HEAD 2>/dev/null || true)" _changed_files="$(git -C "$REPO" diff --name-only "${_base:-HEAD}" HEAD 2>/dev/null || true)" diff --git a/tools/t3_lever_scan.py b/tools/t3_lever_scan.py index 5d5bc557..e393db98 100644 --- a/tools/t3_lever_scan.py +++ b/tools/t3_lever_scan.py @@ -71,7 +71,7 @@ def restore(): def run_mc(n, seed0): cmd = [GODOT, "--headless", "--path", str(ROOT / "game"), "--script", - "res://smoke/adventure_monte_carlo.gd", "--", str(n), str(seed0), "both", "min"] + "res://sim/adventure_monte_carlo.gd", "--", str(n), str(seed0), "both", "min"] proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3600) rows = {"normal": [], "perfect": []} diff --git a/体验验证清单.md b/体验验证清单.md index 7dd32106..a16579e2 100644 --- a/体验验证清单.md +++ b/体验验证清单.md @@ -80,7 +80,7 @@ 2026-09-19 上午拍板):本项为封印不可逆提案的 Alpha 评估前置采集——同日用户在真人试玩中确认"不能挖旗格"违背设定,**提案全套提前落地**(禁移除 + 挖封印格兑付改判,战斗结算 v0.18 四情况表;登记原文见 2026-09-18 状态记录 72 与 git 历史待办清单清单条目)。评估使命终结,本项转为一律机制的真人行为监控(与 ⑥⑦ 同形状,纯日志侧采集零新增埋点)。 - **观测什么**(读数全部可由现役 JSONL 日志推导——动作行含 flag_place/被拒 dig、结算步流含 flag_hit/flag_miss 判定行〔带格坐标〕与 reward_ineffective 作废行;flag_pull 动作行与同轮插撤、移除次数两读数随移除动作退役作废删除):① 误封率 = flag_miss 判定行 ÷ flag_place 动作行(判定时点口径细节随首批读数钉死);② 挖封印格直挖率 = 封印格直接挖开数 ÷ flag_place 动作行(原「被拒后路径分布」三路径随挖封印格放行消失);③ 四情况触发分布(①标中留判 / ②危险格直挖 / ③标空留判 / ④安全格直挖零奖励——④占比是核心读数)。 -- **语义锚(2026-09-19 落地实测锁定,smoke_all `_smoke_flag_irreversible_quadrants` 断言在案)**:封印安全格直挖 = 零惩罚零奖励(正向兑付作废、作废归因行恰一次、标空不判);封印危险格直挖 = 只受挖雷一层惩罚(mine_hit 照常)、标中不发;不挖留判 = 标中/标空照常;flag_pull 出目录拒绝、重复封印 flag_present 拒绝。 +- **语义锚(2026-09-19 落地实测锁定,断言现居 `game/tests/unit/battle_settlement/seal_polarity_test.gd`,套名 `_smoke_seal_irreversible_quadrants`)**:封印安全格直挖 = 零惩罚零奖励(正向兑付作废、作废归因行恰一次、标空不判);封印危险格直挖 = 只受挖雷一层惩罚(mine_hit 照常)、标中不发;不挖留判 = 标中/标空照常;flag_pull 出目录拒绝、重复封印 flag_present 拒绝。 - **预期**:无基线,首跑即基线——随真人试玩批次 2 起采(模拟画像不移除,此面只有真人数据有效)。 - **红灯信号**:误封后放弃纠错占比异常高(直挖免费但④零奖励仍可能劝退纠错——玩家改绕路不挖)或④触发后局部推进明显迟滞(零奖励力度过狠的体验证据)——红灯仅作曲线调参批输入,**不在试玩现场改机制**。 - **结论回流**:读数 → 曲线调参批复核情况④零奖励力度与封印价目;提案已转正,无后续评估义务。 diff --git a/待办清单.md b/待办清单.md index 5f4e8158..7b590e30 100644 --- a/待办清单.md +++ b/待办清单.md @@ -649,9 +649,9 @@ #### L3 屏壳切换探针的判定被仓库外存档状态污染,跑批绿红不可复现(2026-10-18 登记,来源=审计发现,设计面=工程直入) -**现状**:2026-10-18 卫生批跑全量档时 03 探针 --click 与 04 探针 --full 双双「基线绿→现红」,唯一失效单元是 `game/smoke/screen_flow_probe.gd` 的第④步(菜单开新冒险进不去,⑤⑥⑦三条随之因前置不满足连带红)。根因已钉死且与本批及任何代码改动无关:`user://save.json`(主区与所有 worktree 共用同一份、不入 git)里现存一个 `breakpoint` 断点,而 `game/presentation/main.gd:476-482` 按「二轮修改批 A」的用户目检裁决,在 `save_manager.has_breakpoint()` 时弹实底确认框「已有未完冒险(第 N 关)——开新冒险将覆盖旧断点」并直接返回、不开冒险;探针第④步只用前缀匹配找「▶ 开始冒险」并按下、等 3 帧就断言进冒险屏,它隐含假设「存档里没有断点」。三条判据:主区那份未受本批影响的 master(`ea69670c`)单跑同一探针复现完全一样的 7 处;同一 worktree 同一代码把 save.json 临时移开再跑立刻 `PASS — 六步主流程+回退路径全绿`(跑完存档逐字节还原,sha1 `dd67444c35be` 与移开前一致);`game/**/*.gd` 自 10-17 那次绿回执 `7ffd1dc0` 之后零改动。断点来源=上一轮 `ui_click_probe` 或任一会话跑到中途留下的未完冒险(存档账目已累积到 `adventure_total=6`、`level_clear_counts={1:18,2:7,3:4}`)。后果是验证闸的绿红取决于「这台机器上谁最后跑过」,正是 test-conventions §8「判定可靠性优先于覆盖率」所管的同一类缺陷。修复方向两条,均待写卡时定:探针侧自救(第④步前先走弃断点通道,或在确认框上按「开新冒险」再断言)=改 `game/`;跑批侧隔离(给探针配独立 user 目录)=改 `tools/probe_gate.sh`,⚠ 此路有一条未验障碍——本日用 `--user-dir` 指到空目录跑该探针,Godot 挂住零输出、超时未出结论,走这条要先查明成因。证据已指认,按 §12.1 工程直入通道跳过设计轨。 +**现状**:2026-10-18 卫生批跑全量档时 03 探针 --click 与 04 探针 --full 双双「基线绿→现红」,唯一失效单元是 `game/probe/screen_flow_probe.gd` 的第④步(菜单开新冒险进不去,⑤⑥⑦三条随之因前置不满足连带红)。根因已钉死且与本批及任何代码改动无关:`user://save.json`(主区与所有 worktree 共用同一份、不入 git)里现存一个 `breakpoint` 断点,而 `game/presentation/main.gd:476-482` 按「二轮修改批 A」的用户目检裁决,在 `save_manager.has_breakpoint()` 时弹实底确认框「已有未完冒险(第 N 关)——开新冒险将覆盖旧断点」并直接返回、不开冒险;探针第④步只用前缀匹配找「▶ 开始冒险」并按下、等 3 帧就断言进冒险屏,它隐含假设「存档里没有断点」。三条判据:主区那份未受本批影响的 master(`ea69670c`)单跑同一探针复现完全一样的 7 处;同一 worktree 同一代码把 save.json 临时移开再跑立刻 `PASS — 六步主流程+回退路径全绿`(跑完存档逐字节还原,sha1 `dd67444c35be` 与移开前一致);`game/**/*.gd` 自 10-17 那次绿回执 `7ffd1dc0` 之后零改动。断点来源=上一轮 `ui_click_probe` 或任一会话跑到中途留下的未完冒险(存档账目已累积到 `adventure_total=6`、`level_clear_counts={1:18,2:7,3:4}`)。后果是验证闸的绿红取决于「这台机器上谁最后跑过」,正是 test-conventions §8「判定可靠性优先于覆盖率」所管的同一类缺陷。修复方向两条,均待写卡时定:探针侧自救(第④步前先走弃断点通道,或在确认框上按「开新冒险」再断言)=改 `game/`;跑批侧隔离(给探针配独立 user 目录)=改 `tools/probe_gate.sh`,⚠ 此路有一条未验障碍——本日用 `--user-dir` 指到空目录跑该探针,Godot 挂住零输出、超时未出结论,走这条要先查明成因。证据已指认,按 §12.1 工程直入通道跳过设计轨。 -**归属**:game/smoke/screen_flow_probe.gd(探针自救面)与 tools/probe_gate.sh(跑批隔离面)——测试基建 +**归属**:game/probe/screen_flow_probe.gd(探针自救面)与 tools/probe_gate.sh(跑批隔离面)——测试基建 **状态**:待写卡